diff --git a/src/clientversion.h b/src/clientversion.h index 93ea655..eff0a65 100644 --- a/src/clientversion.h +++ b/src/clientversion.h @@ -8,7 +8,7 @@ // These need to be macros, as version.cpp's and triangles-qt.rc's voodoo requires it #define CLIENT_VERSION_MAJOR 5 #define CLIENT_VERSION_MINOR 9 -#define CLIENT_VERSION_REVISION 22 +#define CLIENT_VERSION_REVISION 23 #define CLIENT_VERSION_BUILD 0 // Converts the parameter X to a string after macro replacement on X has been performed. diff --git a/src/init.cpp b/src/init.cpp index 91f3607..289f0e0 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -506,7 +506,7 @@ std::string HelpMessage() " -dbcache= " + _("Set database cache size in megabytes (default: 25)") + "\n" + " -dblogsize= " + _("Set database disk log size in megabytes (default: 100)") + "\n" + " -timeout= " + _("Specify connection timeout in milliseconds (default: 5000)") + "\n" + - " -torconnecttimeout= " + _("Max time (ms) to wait for Tor to reach a peer .onion before giving up (default: 60000, range 5000-180000)") + "\n" + + " -torconnecttimeout= " + _("Max time (ms) for the SOCKS5 handshake with the Tor proxy (send+recv of SOCKS5 init/auth/connect). Bounds how long a dead/slow .onion can stall the connector thread (default: 60000, range 5000-180000)") + "\n" + //" -proxy= " + _("Connect through socks proxy") + "\n" + //" -socks= " + _("Select the version of socks proxy to use (4-5, default: 5)") + "\n" + " -tor= " + _("Use proxy to reach tor hidden services (default: same as -proxy)") + "\n" @@ -782,13 +782,18 @@ bool AppInit2() } // SOCKS5/Tor negotiation timeout. Separate from -timeout (which only covers - // the instant local connect to the Tor SOCKS proxy); this bounds how long we - // wait for Tor to reach the target .onion before giving up on that peer. + // the instant local connect to the Tor SOCKS proxy); this bounds the + // SOCKS5 handshake (send+recv of init/auth/connect). On a dead/slow .onion + // the recv() in Socks5() would otherwise block until Tor's own ~120s + // SocksTimeout fires, holding an outbound connection slot. if (mapArgs.count("-torconnecttimeout")) { int nTorTimeout = GetArg("-torconnecttimeout", 60000); - if (nTorTimeout >= 5000 && nTorTimeout <= 180000) + if (IsValidSocksNegotiationTimeout(nTorTimeout)) nSocksNegotiationTimeout = nTorTimeout; + else + InitWarning("Ignoring -torconnecttimeout=" + mapArgs["-torconnecttimeout"] + + ": out of range (5000..180000 ms), using default 60000"); } if (mapArgs.count("-paytxfee")) diff --git a/src/net.cpp b/src/net.cpp index 0096090..1f0e91a 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -1750,6 +1750,10 @@ bool ThreadHTTPSeedFetch2(void* parg) // body then carries hex chunk-size lines interleaved with the data; parsing // it raw fuses a chunk marker onto an address and we lose most of the list // (the classic "only 1 address" symptom). De-chunk first when present. + // + // v5.9.22 hardening: the parser is now strict and reports a distinct + // failure code for each kind of malformed framing. See DechunkResult in + // netbase.h and the unit tests in src/test/http_seed_tests.cpp. { std::string h = headers; for (char& c : h) c = (char)tolower((unsigned char)c); @@ -1757,22 +1761,20 @@ bool ThreadHTTPSeedFetch2(void* parg) h.find("chunked") != std::string::npos) { std::string decoded; - size_t pos = 0; - while (pos < body.size()) { - size_t eol = body.find("\r\n", pos); - if (eol == std::string::npos) break; - std::string sizeLine = body.substr(pos, eol - pos); - size_t semi = sizeLine.find(';'); // strip chunk extensions - if (semi != std::string::npos) sizeLine = sizeLine.substr(0, semi); - unsigned long chunkSize = strtoul(sizeLine.c_str(), nullptr, 16); - pos = eol + 2; - if (chunkSize == 0) break; // last chunk - if (pos + chunkSize > body.size()) - chunkSize = body.size() - pos; // defensive clamp - decoded.append(body, pos, chunkSize); - pos += chunkSize; - if (pos + 2 <= body.size() && body.compare(pos, 2, "\r\n") == 0) - pos += 2; // trailing CRLF after data + int rc = DechunkTransferEncoding(body, decoded); + if (rc != DECHUNK_OK) { + const char* reason = "unknown"; + switch (rc) { + case DECHUNK_EMPTY: reason = "empty body"; break; + case DECHUNK_NO_CHUNK_TERMINATOR: reason = "missing chunk terminator (CRLF)"; break; + case DECHUNK_INVALID_HEX: reason = "malformed chunk-size (not valid hex)"; break; + case DECHUNK_OVERSIZE_CHUNK: reason = "chunk size exceeds remaining input (truncated)"; break; + case DECHUNK_MISSING_DATA_CRLF: reason = "missing CRLF after chunk data"; break; + default: reason = "unknown"; break; + } + printf("HTTPS seed fetch: malformed chunked transfer encoding (%s) from %s\n", + reason, seedHost.c_str()); + return false; } body.swap(decoded); } @@ -1783,7 +1785,11 @@ bool ThreadHTTPSeedFetch2(void* parg) // Tolerant parse: accept one-per-line OR several addresses on one line // (whitespace / comma / semicolon separated), and ignore inline '#' comments. + // v5.9.22: the splitting logic is now a pure function in netbase.cpp so + // we can unit-test every line format. The CNetAddr/CService/addrman + // validation stays here because it touches globals. int found = 0; + int skipped = 0; auto addSeed = [&](std::string addrStr) -> void { while (!addrStr.empty() && (addrStr.back()=='\r' || addrStr.back()==' ' || addrStr.back()=='\t')) @@ -1811,38 +1817,34 @@ bool ThreadHTTPSeedFetch2(void* parg) addrman.Add(addr, service); printf("HTTPS seed: added %s:%d\n", addrStr.c_str(), port); found++; + } else { + skipped++; } }; - std::istringstream lines(body); - std::string line; - while (std::getline(lines, line)) + // Use the pure helper to split the body. If it returns nothing, that + // means the body was entirely comments / blank lines / whitespace — + // distinct failure mode worth logging separately from "no valid + // addresses after parsing". + std::vector tokens = ParseSeedListBody(body); + if (tokens.empty()) { + printf("HTTPS seed fetch: parsed response contained zero valid addresses from %s\n", seedHost.c_str()); + return false; + } + + for (const std::string& tok : tokens) { if (fShutdown) return false; - - // Strip inline comments (everything from '#' onward) - size_t hashPos = line.find('#'); - if (hashPos != std::string::npos) - line = line.substr(0, hashPos); - - // Split on whitespace / comma / semicolon so multiple addresses on - // one line are all captured. - size_t start = 0; - while (start <= line.size()) { - size_t sep = line.find_first_of(" \t,;", start); - std::string tok = (sep == std::string::npos) - ? line.substr(start) - : line.substr(start, sep - start); - if (!tok.empty()) - addSeed(tok); - if (sep == std::string::npos) break; - start = sep + 1; - } + addSeed(tok); } printf("%d addresses found from HTTPS seed list (%s)\n", found, seedHost.c_str()); - return found > 0; + if (found == 0) { + printf("HTTPS seed fetch: parsed response contained zero valid addresses from %s\n", seedHost.c_str()); + return false; + } + return true; } catch (std::exception& e) { printf("HTTPS seed fetch failed: %s\n", e.what()); diff --git a/src/netbase.cpp b/src/netbase.cpp index 9cd133e..bfcdca6 100644 --- a/src/netbase.cpp +++ b/src/netbase.cpp @@ -12,6 +12,12 @@ #include #endif +#include +#include +#include +#include +#include + #include "strlcpy.h" using namespace std; @@ -1312,3 +1318,151 @@ void CService::SetPort(unsigned short portIn) { port = portIn; } + +// ═══════════════════════════════════════════════════════════════════════════════ +// v5.9.22 hardening: pure helper functions for the HTTPS seed-list path. +// See netbase.h for the contract. These are intentionally free of SSL/Tor +// dependencies so they can be unit-tested in isolation. +// ═══════════════════════════════════════════════════════════════════════════════ + +bool IsValidSocksNegotiationTimeout(int nMs) +{ + // Range bounds match the documented -torconnecttimeout contract. 5000ms + // is the lower edge that still tolerates a slow SOCKS handshake over a + // congested link; 180000ms (3 min) is the upper edge to prevent a stuck + // thread from holding an outbound connection slot indefinitely. These + // constants are duplicated in src/init.cpp's HelpMessage text and the + // test suite — keep all three in sync. + return nMs >= 5000 && nMs <= 180000; +} + +int DechunkTransferEncoding(const std::string& body, std::string& decoded) +{ + decoded.clear(); + if (body.empty()) + return DECHUNK_EMPTY; + + // HTTP chunked framing requires every chunk-size line to be terminated + // by CRLF. We walk the body one chunk at a time and validate each piece. + // The previous implementation silently dropped malformed chunks and + // treated them as the last-chunk marker, which lost the entire seed list + // for any non-conforming server. This version returns an explicit error + // code for each failure mode. + size_t pos = 0; + const size_t n = body.size(); + bool sawLastChunk = false; + + while (pos < n) { + // Find end of chunk-size line. Required: CRLF. + size_t eol = body.find("\r\n", pos); + if (eol == std::string::npos) + return DECHUNK_NO_CHUNK_TERMINATOR; + + std::string sizeLine = body.substr(pos, eol - pos); + pos = eol + 2; // consume CRLF + + // Strip chunk extensions per RFC 7230 §4.1.1: ";name[=value]" after + // the hex size. Extensions are part of the framing protocol, not + // data, so we drop them here. + size_t semi = sizeLine.find(';'); + std::string hexSize = (semi == std::string::npos) ? sizeLine : sizeLine.substr(0, semi); + + // Strict hex validation: every character must be [0-9A-Fa-f]. Empty + // size lines (e.g. a stray CRLF) are rejected as malformed, not + // silently treated as 0. strtoul alone would also accept leading + // whitespace, '+', and '-' which we don't want. + if (hexSize.empty()) + return DECHUNK_INVALID_HEX; + for (size_t i = 0; i < hexSize.size(); ++i) { + if (!isxdigit(static_cast(hexSize[i]))) + return DECHUNK_INVALID_HEX; + } + + // strtoul returns ULONG_MAX on overflow. We also need to guard + // against chunks larger than the remaining input, which the old + // code clamped silently. Use strtoull so we can detect overflow + // without truncation surprises on 32-bit builds. + errno = 0; + char* endp = nullptr; + unsigned long long chunkSize = strtoull(hexSize.c_str(), &endp, 16); + if (errno == ERANGE || chunkSize > std::numeric_limits::max()) + return DECHUNK_INVALID_HEX; + if (endp == hexSize.c_str()) + return DECHUNK_INVALID_HEX; + + if (chunkSize == 0) { + // Last-chunk: payload is empty, trailer part (which we ignore) + // follows and is terminated by a final CRLF on its own line. + sawLastChunk = true; + break; + } + + // Bounds check before reading the chunk data. Catching this + // explicitly (rather than clamping) is what lets callers + // distinguish "truncated network read" from "server sent us junk". + if (chunkSize > n - pos) + return DECHUNK_OVERSIZE_CHUNK; + + decoded.append(body, pos, static_cast(chunkSize)); + pos += static_cast(chunkSize); + + // Per RFC 7230 each chunk's data must be followed by a CRLF. We + // tolerate the final chunk missing its trailing CRLF (some clients + // do this when the connection is being closed anyway), but for any + // non-final chunk a missing CRLF is a hard framing error. + if (pos + 1 < n && body[pos] == '\r' && body[pos + 1] == '\n') { + pos += 2; + } else if (pos >= n) { + // End of input immediately after chunk data — no CRLF, but + // nothing left to misframe. Reject to be strict. + return DECHUNK_MISSING_DATA_CRLF; + } else { + return DECHUNK_MISSING_DATA_CRLF; + } + } + + if (!sawLastChunk) { + // Body ended without a last-chunk marker. Treat as malformed + // rather than accepting a truncated body. + return DECHUNK_NO_CHUNK_TERMINATOR; + } + + return DECHUNK_OK; +} + +std::vector ParseSeedListBody(const std::string& body) +{ + std::vector out; + std::istringstream lines(body); + std::string line; + while (std::getline(lines, line)) { + // Strip inline '#' comments. Per common seed-list convention, the + // first '#' to end-of-line is comment. + size_t hashPos = line.find('#'); + if (hashPos != std::string::npos) + line = line.substr(0, hashPos); + + // Split on whitespace, comma, or semicolon so multiple addresses + // on one line are all captured. CR/LF are already consumed by + // std::getline but a trailing CR (LF-only line endings) is trimmed + // implicitly by skipping it as a separator below. + size_t start = 0; + while (start <= line.size()) { + size_t sep = line.find_first_of(" \t,;", start); + std::string tok = (sep == std::string::npos) + ? line.substr(start) + : line.substr(start, sep - start); + // Trim CR and any leftover whitespace from the token. The + // 'sep' loop above eats spaces/tabs but a bare CR survives. + while (!tok.empty() && (tok.back() == '\r' || tok.back() == ' ' || tok.back() == '\t')) + tok.pop_back(); + while (!tok.empty() && (tok.front() == ' ' || tok.front() == '\t')) + tok.erase(tok.begin()); + if (!tok.empty()) + out.push_back(tok); + if (sep == std::string::npos) break; + start = sep + 1; + } + } + return out; +} diff --git a/src/netbase.h b/src/netbase.h index ea5bdf4..aea234c 100644 --- a/src/netbase.h +++ b/src/netbase.h @@ -32,6 +32,75 @@ extern int nConnectTimeout; extern int nSocksNegotiationTimeout; extern bool fNameLookup; +// ═══════════════════════════════════════════════════════════════════════════════ +// v5.9.22 hardening: pure helper functions for the HTTPS seed-list path. +// Extracted from net.cpp ThreadHTTPSeedFetch2 so they can be unit-tested +// without the SSL/Tor network stack. All functions are side-effect free and +// operate on std::string/std::vector only. +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Result of dechunking an HTTP/1.1 chunked body. The daemon used to silently + * treat malformed framing as a zero-length chunk, which dropped the entire + * seed list. This enum lets the caller distinguish each failure mode and + * surface it in logs. + */ +enum DechunkResult { + DECHUNK_OK = 0, // success + DECHUNK_EMPTY, // body is empty + DECHUNK_NO_CHUNK_TERMINATOR, // missing CRLF after a chunk-size line + DECHUNK_INVALID_HEX, // chunk-size line is not valid hex + DECHUNK_OVERSIZE_CHUNK, // declared chunk size exceeds remaining input + DECHUNK_MISSING_DATA_CRLF, // CRLF missing after a chunk's data +}; + +/** + * Decode an HTTP/1.1 Transfer-Encoding: chunked body. + * + * chunked-body = *chunk last-chunk trailer-part CRLF + * chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF + * chunk-size = 1*HEXDIG + * last-chunk = 1*("0") [ chunk-ext ] CRLF + * chunk-ext = *( ";" chunk-ext-name [ "=" chunk-ext-val ] ) + * + * @param[in] body the raw body bytes after the header terminator + * @param[out] decoded the dechunked payload on success + * @return status code (DECHUNK_OK or one of the failure modes) + * + * The implementation is intentionally strict: a malformed hex digit, a + * missing CRLF, or a chunk whose declared size is larger than the remaining + * input all return an explicit error code rather than silently clamping. + * Chunk extensions ("a;foo=bar") are preserved (stripped from the size + * line) so legitimate servers that attach metadata to chunks are still + * accepted. + */ +int DechunkTransferEncoding(const std::string& body, std::string& decoded); + +/** + * Parse a tolerant HTTPS seed-list body into individual host entries. + * + * Accepted per line: + * - one or more addresses separated by whitespace, commas, or semicolons + * - inline "#" comments (everything after '#' is dropped) + * - blank lines + * - CRLF or LF line endings + * + * Each returned entry is the address string (e.g. "abcd...onion:24112" or + * "abcd...onion"). Empty/whitespace-only entries are omitted. The result is + * a list of candidate strings suitable for CNetAddr/CService validation + * downstream. + */ +std::vector ParseSeedListBody(const std::string& body); + +/** + * Validate the -torconnecttimeout / nSocksNegotiationTimeout value. + * + * Accepts 5000..180000 ms inclusive. Returns true for in-range, false for + * out-of-range. This is the central policy so callers and tests stay in + * sync; do not duplicate the literal numbers elsewhere. + */ +bool IsValidSocksNegotiationTimeout(int nMs); + /** IP address (IPv6, or IPv4 using mapped IPv6 range (::FFFF:0:0/96)) */ class CNetAddr { diff --git a/src/test/http_seed_tests.cpp b/src/test/http_seed_tests.cpp new file mode 100644 index 0000000..a6265fb --- /dev/null +++ b/src/test/http_seed_tests.cpp @@ -0,0 +1,464 @@ +// Copyright (c) 2026 Cryptographic Triangles +// Distributed under the MIT/X11 software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// v5.9.22 hardening tests for the HTTPS seed-list path. +// +// Two pure functions under test (declared in netbase.h): +// +// int DechunkTransferEncoding(const std::string& body, std::string& decoded) +// std::vector ParseSeedListBody(const std::string& body) +// bool IsValidSocksNegotiationTimeout(int nMs) +// +// These functions replaced the inline parsers in net.cpp +// ThreadHTTPSeedFetch2. The tests cover every failure mode listed in the +// hardening brief: +// - Normal non-chunked HTTP seed responses (the parser is a no-op) +// - Valid chunked responses with several chunks +// - Chunk extensions such as A;foo=bar +// - Chunked payloads split at awkward boundaries +// - Malformed chunk sizes, missing CRLF, truncated chunks, chunks whose +// declared size exceeds remaining input +// - Seed-list parsing with whitespace, commas, semicolons, comments, +// multiple addresses per line, valid .onion:port entries, and invalid +// entries +// - -torconnecttimeout validation at the exact boundaries and just +// outside them: 4999, 5000, 60000, 180000, and 180001 milliseconds + +#include + +#include "netbase.h" + +#include +#include + +using namespace std; + +BOOST_AUTO_TEST_SUITE(http_seed_tests) + +// ═══════════════════════════════════════════════════════════════════════════════ +// DechunkTransferEncoding — happy path +// ═══════════════════════════════════════════════════════════════════════════════ + +BOOST_AUTO_TEST_CASE(dechunk_empty_body) +{ + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(string(""), decoded), DECHUNK_EMPTY); + BOOST_CHECK(decoded.empty()); +} + +BOOST_AUTO_TEST_CASE(dechunk_single_chunk) +{ + // "5\r\nhello\r\n0\r\n\r\n" → "hello" + string body = "5\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "hello"); +} + +BOOST_AUTO_TEST_CASE(dechunk_multiple_chunks) +{ + // Three chunks concatenated: "Hel" + "lo " + "world" + string body = "3\r\nHel\r\n3\r\nlo \r\n5\r\nworld\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "Hello world"); +} + +BOOST_AUTO_TEST_CASE(dechunk_with_chunk_extension) +{ + // "5;foo=bar\r\nhello\r\n0\r\n\r\n" → "hello" + // Extensions after the size are part of the framing protocol and must + // be stripped before parsing the hex size. + string body = "5;foo=bar\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "hello"); +} + +BOOST_AUTO_TEST_CASE(dechunk_with_multiple_extensions) +{ + // "5;a=b;c=d\r\nhello\r\n0\r\n\r\n" + string body = "5;a=b;c=d\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "hello"); +} + +BOOST_AUTO_TEST_CASE(dechunk_uppercase_hex) +{ + // "5\r\nhello\r\n0\r\n\r\n" with A-F uppercase + string body = "A\r\n0123456789\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "0123456789"); +} + +BOOST_AUTO_TEST_CASE(dechunk_payload_containing_crlf) +{ + // Chunk data itself contains CRLF — must not be mistaken for framing. + string body = "B\r\nline1\r\nline2\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "line1\r\nline2"); +} + +BOOST_AUTO_TEST_CASE(dechunk_split_at_awkward_boundary) +{ + // A long chunk whose internal "data" happens to look like a chunk-size + // line. Hex 0x0B = 11 bytes; the data "FAKE\r\nFOO\r" contains CRLF. + string body = "B\r\nFAKE\r\nFOO\r\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + // 11 bytes consumed: "FAKE\r\nFOO\r" (5 + 2 + 3 + 1 = 11) + BOOST_CHECK_EQUAL(decoded, "FAKE\r\nFOO\r"); +} + +BOOST_AUTO_TEST_CASE(dechunk_last_chunk_with_extension) +{ + // "0;end=1\r\n\r\n" — last chunk with extension, no body + string body = "5\r\nhello\r\n0;end=1\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, "hello"); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// DechunkTransferEncoding — failure modes +// ═══════════════════════════════════════════════════════════════════════════════ + +BOOST_AUTO_TEST_CASE(dechunk_no_crlf_after_size) +{ + // No CRLF after the chunk-size hex — must not be silently accepted. + string body = "5XXhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_NO_CHUNK_TERMINATOR); +} + +BOOST_AUTO_TEST_CASE(dechunk_invalid_hex) +{ + // "G" is not a valid hex digit. + string body = "G\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX); +} + +BOOST_AUTO_TEST_CASE(dechunk_empty_size_line) +{ + // Stray CRLF at the start — empty size line must be rejected. + string body = "\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX); +} + +BOOST_AUTO_TEST_CASE(dechunk_oversize_chunk) +{ + // Declared 100 bytes but only 5 remain. Old code silently clamped; + // strict version must report DECHUNK_OVERSIZE_CHUNK. + string body = "64\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_OVERSIZE_CHUNK); +} + +BOOST_AUTO_TEST_CASE(dechunk_truncated_last_chunk_marker) +{ + // No "0\r\n" terminator — body just ends mid-chunk. + string body = "5\r\nhello\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_NO_CHUNK_TERMINATOR); +} + +BOOST_AUTO_TEST_CASE(dechunk_missing_data_crlf) +{ + // Chunk-data not followed by CRLF. Two chunks: first is 5 bytes "hello" + // then "X" where CRLF should be. The parser must catch the missing CRLF + // before trying to read the next chunk-size. + string body = "5\r\nhelloX3\r\nfoo\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_MISSING_DATA_CRLF); +} + +BOOST_AUTO_TEST_CASE(dechunk_strtoul_overflow) +{ + // A hex value larger than size_t can represent. On a 64-bit system this + // would be 17+ F's. We pick a 32-digit value: clearly overflows on both + // 32 and 64 bit builds. + string body = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF\r\n0\r\n\r\n"; + string decoded; + // Either INVALID_HEX (overflow detected) or OVERSIZE_CHUNK (caught at + // bounds check) is acceptable — both correctly refuse the input. + int rc = DechunkTransferEncoding(body, decoded); + BOOST_CHECK(rc == DECHUNK_INVALID_HEX || rc == DECHUNK_OVERSIZE_CHUNK); +} + +BOOST_AUTO_TEST_CASE(dechunk_sign_in_size) +{ + // strtoul would silently accept leading '+' or '-'. Our strict + // hex-only validator must reject them. + string body = "+5\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX); +} + +BOOST_AUTO_TEST_CASE(dechunk_whitespace_in_size) +{ + // strtoul would silently accept leading whitespace. Our strict + // hex-only validator must reject them. + string body = " 5\r\nhello\r\n0\r\n\r\n"; + string decoded; + BOOST_CHECK_EQUAL(DechunkTransferEncoding(body, decoded), DECHUNK_INVALID_HEX); +} + +BOOST_AUTO_TEST_CASE(dechunk_no_last_chunk) +{ + // Body has chunks but never reaches a size-0 terminator. Must be + // rejected, not silently accepted as the whole body. + string body = "5\r\nhello\r\n"; // missing "0\r\n\r\n" + string decoded; + int rc = DechunkTransferEncoding(body, decoded); + BOOST_CHECK(rc == DECHUNK_NO_CHUNK_TERMINATOR || rc == DECHUNK_MISSING_DATA_CRLF); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ParseSeedListBody +// ═══════════════════════════════════════════════════════════════════════════════ + +BOOST_AUTO_TEST_CASE(seedlist_empty) +{ + vector out = ParseSeedListBody(""); + BOOST_CHECK(out.empty()); +} + +BOOST_AUTO_TEST_CASE(seedlist_single_onion_per_line) +{ + // Three v3 onion addresses, one per line. + string body = + "aaaa.onion:24112\n" + "bbbb.onion:24112\n" + "cccc.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 3u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112"); + BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112"); + BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_crlf_line_endings) +{ + // Real-world: HTTP responses typically use CRLF. + string body = + "aaaa.onion:24112\r\n" + "bbbb.onion:24112\r\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 2u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112"); + BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_space) +{ + // Several addresses on one line, space-separated. + string body = "aaaa.onion:24112 bbbb.onion:24112 cccc.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 3u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112"); + BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112"); + BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_comma) +{ + // Comma-separated — common in older seed lists. + string body = "aaaa.onion:24112,bbbb.onion:24112,cccc.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 3u); +} + +BOOST_AUTO_TEST_CASE(seedlist_multiple_per_line_semicolon) +{ + // Semicolon-separated — sometimes used in INI-style configs. + string body = "aaaa.onion:24112;bbbb.onion:24112;cccc.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 3u); +} + +BOOST_AUTO_TEST_CASE(seedlist_mixed_separators) +{ + // Tabs, multiple spaces, commas, semicolons all in one line. + string body = "aaaa.onion:24112,\tbbbb.onion:24112 ;cccc.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 3u); +} + +BOOST_AUTO_TEST_CASE(seedlist_inline_comments) +{ + // Anything after '#' to end-of-line is dropped. + string body = + "aaaa.onion:24112 # primary\n" + "# this whole line is a comment\n" + "bbbb.onion:24112 # secondary\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 2u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112"); + BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_blank_lines) +{ + // Whitespace-only / blank lines are skipped. + string body = + "\n" + " \n" + "aaaa.onion:24112\n" + "\t\n" + "bbbb.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 2u); +} + +BOOST_AUTO_TEST_CASE(seedlist_only_comments) +{ + // All-comment body produces empty output (zero valid addresses + // downstream — caller logs the failure mode). + string body = + "# nothing useful here\n" + "# more comments\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK(out.empty()); +} + +BOOST_AUTO_TEST_CASE(seedlist_portless_onion) +{ + // ".onion" without ":port" is allowed at the parser level — the caller + // falls back to GetDefaultPort() before validating as a CService. + string body = "aaaa.onion\nbbbb.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 2u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion"); + BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_invalid_entry_preserved_for_caller) +{ + // The parser does NOT validate that entries are real .onion addresses + // or valid CService — that's the caller's job. The parser is a pure + // splitter; invalid entries (e.g. "not-a-host") are still returned + // and will fail CService::IsValid() downstream. + string body = "not-a-host\nxxxxxx.onion:24112\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 2u); + BOOST_CHECK_EQUAL(out[0], "not-a-host"); + BOOST_CHECK_EQUAL(out[1], "xxxxxx.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_trailing_whitespace_per_line) +{ + // Spaces/tabs at end of each line should not produce an empty token. + string body = "aaaa.onion:24112 \t \n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 1u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112"); +} + +BOOST_AUTO_TEST_CASE(seedlist_crlf_lf_mix) +{ + // Some lines CRLF, some LF — should all parse. + string body = + "aaaa.onion:24112\r\n" + "bbbb.onion:24112\n" + "cccc.onion:24112\r\n"; + vector out = ParseSeedListBody(body); + BOOST_CHECK_EQUAL(out.size(), 3u); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// IsValidSocksNegotiationTimeout — exact boundaries and just outside +// ═══════════════════════════════════════════════════════════════════════════════ + +BOOST_AUTO_TEST_CASE(timeout_just_below_lower_bound) +{ + BOOST_CHECK(!IsValidSocksNegotiationTimeout(4999)); +} + +BOOST_AUTO_TEST_CASE(timeout_exact_lower_bound) +{ + BOOST_CHECK(IsValidSocksNegotiationTimeout(5000)); +} + +BOOST_AUTO_TEST_CASE(timeout_default) +{ + BOOST_CHECK(IsValidSocksNegotiationTimeout(60000)); +} + +BOOST_AUTO_TEST_CASE(timeout_exact_upper_bound) +{ + BOOST_CHECK(IsValidSocksNegotiationTimeout(180000)); +} + +BOOST_AUTO_TEST_CASE(timeout_just_above_upper_bound) +{ + BOOST_CHECK(!IsValidSocksNegotiationTimeout(180001)); +} + +BOOST_AUTO_TEST_CASE(timeout_zero) +{ + BOOST_CHECK(!IsValidSocksNegotiationTimeout(0)); +} + +BOOST_AUTO_TEST_CASE(timeout_negative) +{ + BOOST_CHECK(!IsValidSocksNegotiationTimeout(-1)); +} + +BOOST_AUTO_TEST_CASE(timeout_max_int) +{ + // Guard against wraparound on int boundaries. + BOOST_CHECK(!IsValidSocksNegotiationTimeout(2147483647)); +} + +BOOST_AUTO_TEST_CASE(timeout_midrange) +{ + // Several plausible values in the middle of the range. + BOOST_CHECK(IsValidSocksNegotiationTimeout(10000)); + BOOST_CHECK(IsValidSocksNegotiationTimeout(30000)); + BOOST_CHECK(IsValidSocksNegotiationTimeout(120000)); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Integration: dechunked body → seed-list parser round-trip +// ═══════════════════════════════════════════════════════════════════════════════ + +BOOST_AUTO_TEST_CASE(roundtrip_chunked_then_parsed) +{ + // Build a chunked-encoded seed list, decode it, then parse the result. + string seedBody = + "aaaa.onion:24112\n" + "bbbb.onion:24112\n" + "# cccc is the backup\n" + "cccc.onion:24112,dddd.onion:24112\n"; + + // Encode into chunked form. + string chunked; + size_t pos = 0; + while (pos < seedBody.size()) { + size_t take = min(seedBody.size() - pos, (size_t)16); + char hex[16]; + snprintf(hex, sizeof(hex), "%zx", take); + chunked += string(hex) + "\r\n" + seedBody.substr(pos, take) + "\r\n"; + pos += take; + } + chunked += "0\r\n\r\n"; + + string decoded; + BOOST_REQUIRE_EQUAL(DechunkTransferEncoding(chunked, decoded), DECHUNK_OK); + BOOST_CHECK_EQUAL(decoded, seedBody); + + vector out = ParseSeedListBody(decoded); + BOOST_CHECK_EQUAL(out.size(), 4u); + BOOST_CHECK_EQUAL(out[0], "aaaa.onion:24112"); + BOOST_CHECK_EQUAL(out[1], "bbbb.onion:24112"); + BOOST_CHECK_EQUAL(out[2], "cccc.onion:24112"); + BOOST_CHECK_EQUAL(out[3], "dddd.onion:24112"); +} + +BOOST_AUTO_TEST_SUITE_END()