// Unit tests for mux_subnet::compare_to — the subnet-vs-subnet comparison // that drives the access-control (site-ban) tree. This is the logic whose // shared-base / shared-end containment bug (#799) inverted the ban tree so a // /8 forbid silently stopped applying once a same-base /24 rule was added. // // There was previously NO test for this code path: fun_subnetmatch() exercises // only the address-vs-subnet overload, and the tree itself lives in net.cpp // (whole-driver dependencies). This harness links the netmux-side netaddr // object against libmux with a handful of driver-global stubs and tests the // comparator directly, so the #799 class of bug (and the historically-buggy // operator==/< and tree logic that feeds it) is locked mechanically. // // Build/run: make test (needs a built netmux: mux/src/netmux-netaddr.o) #include #include #include #include "autoconf.h" #include "config.h" // inet_pton, for building test sockaddrs. On Windows it comes from // , which config.h has already included above along with // winsock2.h in the required order; does not exist there. // config.h therefore has to precede this, which is why the include moved // below it rather than being wrapped in place (#1441). // #if !defined(WIN32) #include #endif #include "alloc.h" // --- Driver-global stubs --------------------------------------------------- // netaddr.o references these driver-owned globals. The compare_to / // parse_subnet happy path (valid CIDR input) never dereferences the two // nullable COM interfaces; g_bStandAlone just selects allocation behavior. class mux_ILog; class mux_INotify; bool g_bStandAlone = true; mux_ILog *g_pILog = nullptr; mux_INotify *g_pINotify = nullptr; // netaddr.cpp also exports the graduated-site-rule threshold splitter; it is // declared in externs.h, which drags in the whole driver, so declare it here. extern bool parse_site_threshold(UTF8 *str, unsigned long *pulThreshold); // --- tiny test framework --------------------------------------------------- static int g_pass = 0; static int g_fail = 0; using SC = mux_subnet::SubnetComparison; static const char *scname(SC c) { switch (c) { case SC::kLessThan: return "kLessThan"; case SC::kEqual: return "kEqual"; case SC::kContains: return "kContains"; case SC::kContainedBy: return "kContainedBy"; case SC::kGreaterThan: return "kGreaterThan"; } return "?"; } static mux_subnet *make_subnet(const char *cidr) { UTF8 buf[64]; std::strncpy(reinterpret_cast(buf), cidr, sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; return parse_subnet(buf, 0, nullptr); } // Assert compare_to(a, b) == want. static void expect(const char *a, const char *b, SC want) { mux_subnet *sa = make_subnet(a); mux_subnet *sb = make_subnet(b); if (nullptr == sa || nullptr == sb) { g_fail++; printf("FAIL: parse_subnet failed for \"%s\" or \"%s\"\n", a, b); delete sa; delete sb; return; } SC got = sa->compare_to(sb); if (got == want) { g_pass++; } else { g_fail++; printf("FAIL: compare_to(%-16s, %-16s) = %-13s want %s\n", a, b, scname(got), scname(want)); } delete sa; delete sb; } // Assert parse_subnet(cidr) rejects the input (returns nullptr). These drive // the error paths (which call the netmux-side cf_log_syntax); that is // nullptr-safe here because driver_log.h's STARTLOG guards on g_pILog. static void expect_reject(const char *cidr) { mux_subnet *sn = make_subnet(cidr); if (nullptr == sn) { g_pass++; } else { g_fail++; printf("FAIL: parse_subnet(\"%s\") should have been rejected but parsed\n", cidr); delete sn; } } // Assert parse_subnet(cidr) accepts the input (returns non-nullptr). static void expect_accept(const char *cidr) { mux_subnet *sn = make_subnet(cidr); if (nullptr != sn) { g_pass++; delete sn; } else { g_fail++; printf("FAIL: parse_subnet(\"%s\") should have been accepted but was rejected\n", cidr); } } // --- address-vs-subnet (compare_to(MUX_SOCKADDR*)) helpers ---------------- // Build a MUX_SOCKADDR for a native IPv4 dotted address. static MUX_SOCKADDR sockaddr_v4(const char *ip) { struct sockaddr_in sin; std::memset(&sin, 0, sizeof(sin)); sin.sin_family = AF_INET; inet_pton(AF_INET, ip, &sin.sin_addr); return MUX_SOCKADDR(reinterpret_cast(&sin)); } // Build a MUX_SOCKADDR for an IPv6 address (string form, may be ::ffff:a.b.c.d). static MUX_SOCKADDR sockaddr_v6(const char *ip) { struct sockaddr_in6 sin6; std::memset(&sin6, 0, sizeof(sin6)); sin6.sin6_family = AF_INET6; inet_pton(AF_INET6, ip, &sin6.sin6_addr); return MUX_SOCKADDR(reinterpret_cast(&sin6)); } // Build a MUX_SOCKADDR for a native IPv4 dotted address with an explicit port. static MUX_SOCKADDR sockaddr_v4_port(const char *ip, unsigned short port) { struct sockaddr_in sin; std::memset(&sin, 0, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_port = htons(port); inet_pton(AF_INET, ip, &sin.sin_addr); return MUX_SOCKADDR(reinterpret_cast(&sin)); } // Build a MUX_SOCKADDR for an IPv6 address with an explicit port. static MUX_SOCKADDR sockaddr_v6_port(const char *ip, unsigned short port) { struct sockaddr_in6 sin6; std::memset(&sin6, 0, sizeof(sin6)); sin6.sin6_family = AF_INET6; sin6.sin6_port = htons(port); inet_pton(AF_INET6, ip, &sin6.sin6_addr); return MUX_SOCKADDR(reinterpret_cast(&sin6)); } // Assert a plain boolean expectation. static void expect_bool(bool got, const char *label) { if (got) { g_pass++; } else { g_fail++; printf("FAIL: %s\n", label); } } // Assert same_address(a, b) == want. Also asserts the relationship to // operator==, which differs precisely by including the port. static void expect_same_addr(MUX_SOCKADDR a, MUX_SOCKADDR b, bool want, const char *label) { bool got = a.same_address(b); if (got == want) { g_pass++; } else { g_fail++; printf("FAIL: same_address(%s) = %s want %s\n", label, got ? "true" : "false", want ? "true" : "false"); } } // Assert compare_to(addr) for a subnet == want. `inside` true => the address // is expected to be within the subnet (kContains). static void expect_addr(const char *subnet, MUX_SOCKADDR addr, bool inside, const char *label) { mux_subnet *sn = make_subnet(subnet); if (nullptr == sn) { g_fail++; printf("FAIL: parse_subnet failed for \"%s\"\n", subnet); return; } SC got = sn->compare_to(&addr); bool isInside = (SC::kContains == got); if (isInside == inside) { g_pass++; } else { g_fail++; printf("FAIL: %-28s vs %-16s = %-13s (%s) want %s\n", label, subnet, scname(got), isInside ? "inside" : "outside", inside ? "inside" : "outside"); } delete sn; } int main() { // Windows requires winsock to be initialised before getaddrinfo and // inet_pton will do anything. netmux does this during startup, so // netaddr.cpp itself has no reason to; a standalone harness does. // // Without it the IPv4 cases still pass -- they never enter the resolver -- // while every IPv6 parse_subnet() fails, which reads convincingly like an // IPv6 bug rather than an uninitialised library (#1441). // #if defined(WIN32) WSADATA wsa; if (0 != WSAStartup(MAKEWORD(2, 2), &wsa)) { printf("FATAL: WSAStartup failed\n"); return 1; } #endif pool_init(POOL_LBUF, LBUF_SIZE); // --- #799: nested CIDRs that share a base address ----------------------- // The regression: strict '<' on both bounds misclassified these as // kContainedBy (inverting containment). Must now be kContains. expect("10.0.0.0/8", "10.0.0.0/24", SC::kContains); expect("10.0.0.0/24", "10.0.0.0/8", SC::kContainedBy); expect("192.168.0.0/16", "192.168.0.0/24", SC::kContains); // Nested CIDRs that share an END address (0.0.0.0/1 ends at // 127.255.255.255, same as 127.0.0.0/8). expect("0.0.0.0/1", "127.0.0.0/8", SC::kContains); expect("127.0.0.0/8", "0.0.0.0/1", SC::kContainedBy); // --- equality ----------------------------------------------------------- expect("10.0.0.0/8", "10.0.0.0/8", SC::kEqual); expect("172.16.0.0/12","172.16.0.0/12",SC::kEqual); // --- strict nesting (distinct base AND end) ----------------------------- expect("10.0.0.0/8", "10.1.0.0/16", SC::kContains); expect("10.1.0.0/16", "10.0.0.0/8", SC::kContainedBy); // --- disjoint ----------------------------------------------------------- expect("10.0.0.0/8", "192.168.0.0/16", SC::kLessThan); expect("192.168.0.0/16","10.0.0.0/8", SC::kGreaterThan); // --- IPv6: same shared-base nesting must classify correctly ------------- expect("2001:db8::/32","2001:db8::/48", SC::kContains); expect("2001:db8::/48","2001:db8::/32", SC::kContainedBy); expect("2001:db8::/32","2001:db8::/32", SC::kEqual); // --- address-vs-subnet, including the #800 IPv4-mapped IPv6 case -------- // Native IPv4 inside / outside a v4 subnet. expect_addr("1.2.3.0/24", sockaddr_v4("1.2.3.4"), true, "v4 1.2.3.4"); expect_addr("1.2.3.0/24", sockaddr_v4("1.2.4.4"), false, "v4 1.2.4.4"); // #800: an IPv4-mapped IPv6 source (::ffff:1.2.3.4 -- what a dual-stack // listener delivers for an inbound IPv4 connection) must match the v4 rule // exactly as the native form does. Without canonicalization the // cross-family compare sorts it past every v4 subnet (kLessThan) and the // ban is bypassed -- this case fails if the v4-mapped handling is removed. expect_addr("1.2.3.0/24", sockaddr_v6("::ffff:1.2.3.4"), true, "v4-mapped ::ffff:1.2.3.4"); expect_addr("1.2.4.0/24", sockaddr_v6("::ffff:1.2.3.4"), false, "v4-mapped ::ffff:1.2.3.4 (outside)"); // A genuine (non-mapped) IPv6 address still matches v6 rules and not v4. expect_addr("2001:db8::/32", sockaddr_v6("2001:db8::1"), true, "v6 2001:db8::1"); expect_addr("1.2.3.0/24", sockaddr_v6("2001:db8::1"), false, "v6 2001:db8::1 (vs v4 rule)"); // --- parse_subnet rejection paths (previously untested) ----------------- // The subnet parser gates the access-control (site-ban) rule set, so its // rejection of malformed input is security-adjacent. make_subnet() treated // any nullptr as a hard FAIL, so nothing exercised these branches. // // Non-numeric / empty CIDR mask field. expect_reject("10.0.0.0/abc"); expect_reject("10.0.0.0/"); // CIDR prefix length out of range (v4 0..32, v6 0..128). expect_reject("10.0.0.0/33"); expect_reject("10.0.0.0/-1"); expect_reject("2001:db8::/129"); // #1774: oversized digit strings must not truncate into int and accept // as /0 or /1 (4294967296 → 0, 4294967297 → 1 on LLP64/LP64 int). expect_reject("10.0.0.0/4294967296"); expect_reject("10.0.0.0/4294967297"); expect_reject("2001:db8::/4294967296"); // #1774 residual: mux_atoi64 wraps mod 2^64, so a 20+-digit prefix // wraps back into [0,128] (2^64+1 -> 1, 2^64 -> 0) and would pass the // int64 range check. The significant-digit guard rejects these. expect_reject("10.0.0.0/18446744073709551617"); // 2^64 + 1 -> 1 expect_reject("10.0.0.0/18446744073709551616"); // 2^64 -> 0 expect_reject("2001:db8::/18446744073709551744"); // 2^64+128 -> 128 expect_reject("10.0.0.0/999999999999999999999999"); // way over, -> wraps // Leading zeros on a legitimate prefix must still be accepted (the guard // counts significant digits, not raw length). expect_accept("10.0.0.0/024"); expect_accept("2001:db8::/00128"); // Legitimate edges still accepted. expect_accept("10.0.0.0/0"); expect_accept("10.0.0.0/32"); expect_accept("2001:db8::/0"); expect_accept("2001:db8::/128"); // Missing mask (no '/' and no whitespace-delimited netmask). expect_reject("10.0.0.0"); expect_reject(""); // Malformed host address (bad octet / not an address at all). expect_reject("10.0.0.999/24"); expect_reject("not-an-ip/24"); // --- parse_subnet accepts and normalizes host bits set outside the mask - // 10.0.0.1/24 has a host bit set; parse_subnet clears it ("fixed") rather // than rejecting, yielding a subnet equal to 10.0.0.0/24. Verify both the // acceptance and that the normalization is what compare_to sees. expect_accept("10.0.0.1/24"); expect("10.0.0.1/24", "10.0.0.0/24", SC::kEqual); // --- IPv6 shared-END nesting (the v6 analogue of the 0.0.0.0/1 case) ---- // ::/1 spans ::..7fff:ffff:...:ffff; 7fff::/16 ends at that same address, // so the shared-end containment must classify as kContains, not kEqual / // kContainedBy (the #799 bug class, now on the v6 path). expect("::/1", "7fff::/16", SC::kContains); expect("7fff::/16", "::/1", SC::kContainedBy); // --- same_address: address-only equality (port ignored) --------------- // This is the whole reason the method exists: every connection from one // peer has a different source port, so operator== (which includes the // port) cannot group them. Used by the per-source pre-auth cap. expect_same_addr(sockaddr_v4_port("10.0.0.7", 40001), sockaddr_v4_port("10.0.0.7", 40002), true, "v4 same addr, different ports"); expect_same_addr(sockaddr_v4_port("10.0.0.7", 40001), sockaddr_v4_port("10.0.0.8", 40001), false, "v4 different addr, same port"); expect_same_addr(sockaddr_v6_port("2001:db8::1", 40001), sockaddr_v6_port("2001:db8::1", 40002), true, "v6 same addr, different ports"); expect_same_addr(sockaddr_v6_port("2001:db8::1", 40001), sockaddr_v6_port("2001:db8::2", 40001), false, "v6 differs in last hextet"); // Cross-family never matches, including the v4-mapped form: mux_sockaddr // stores what the kernel handed back without normalizing, so a v4-mapped // v6 address is a distinct value from its native v4 twin. Matches // operator== semantics; both forms of one peer cannot arrive on the same // listener, so per-source grouping is unaffected. expect_same_addr(sockaddr_v4_port("127.0.0.1", 40001), sockaddr_v6_port("::ffff:127.0.0.1", 40001), false, "v4 vs v4-mapped v6"); // A different port must NOT make operator== agree with same_address -- // that difference is the contract. { MUX_SOCKADDR a = sockaddr_v4_port("10.0.0.7", 40001); MUX_SOCKADDR b = sockaddr_v4_port("10.0.0.7", 40002); if (a.same_address(b) && !(a == b)) { g_pass++; } else { g_fail++; printf("FAIL: same_address must ignore the port where " "operator== does not\n"); } } // --- source_key: throttling key, IPv6 collapsed to its /64 ----------- // A per-source table keyed on the FULL v6 address is worthless: one // customer normally holds a whole /64, so a single host can source 2**64 // addresses -- evading any per-source throttle and flooding the table // with single-use entries at the same time. Key on the /64. { UTF8 k1[8], k2[8]; MUX_SOCKADDR v4a = sockaddr_v4_port("192.0.2.10", 1); MUX_SOCKADDR v4b = sockaddr_v4_port("192.0.2.11", 2); size_t n1 = v4a.source_key(k1, sizeof(k1)); size_t n2 = v4b.source_key(k2, sizeof(k2)); expect_bool(4 == n1 && 4 == n2, "source_key v4 length is 4"); expect_bool(0 != memcmp(k1, k2, 4), "source_key v4 distinguishes hosts"); // Same /64, wildly different interface identifiers -> ONE key. MUX_SOCKADDR s1 = sockaddr_v6_port("2001:db8:1:2::1", 1); MUX_SOCKADDR s2 = sockaddr_v6_port("2001:db8:1:2:ffff:ffff:ffff:ffff", 2); n1 = s1.source_key(k1, sizeof(k1)); n2 = s2.source_key(k2, sizeof(k2)); expect_bool(8 == n1 && 8 == n2, "source_key v6 length is 8 (/64)"); expect_bool(0 == memcmp(k1, k2, 8), "source_key v6 collapses a whole /64 to one key"); // A different /64 must be a different key. MUX_SOCKADDR s3 = sockaddr_v6_port("2001:db8:1:3::1", 1); n2 = s3.source_key(k2, sizeof(k2)); expect_bool(0 != memcmp(k1, k2, 8), "source_key v6 separates /64s"); // Differing lengths keep v4 and v6 keys from ever colliding. expect_bool(v4a.source_key(k1, sizeof(k1)) != s1.source_key(k2, sizeof(k2)), "source_key lengths differ across families"); // Too small a buffer must fail closed, not overrun. expect_bool(0 == s1.source_key(k1, 7), "source_key rejects short buffer"); // same_source_key must match source_key grouping (v6 /64 collapse). expect_bool(s1.same_source_key(s2), "same_source_key v6 collapses a whole /64"); expect_bool(!s1.same_source_key(s3), "same_source_key v6 separates /64s"); expect_bool(!v4a.same_source_key(v4b), "same_source_key v4 distinguishes hosts"); expect_bool(!v4a.same_source_key(s1), "same_source_key does not match across families"); } // --- parse_site_threshold: graduated site rules ---------------------- // The trailing-token strip must cut the LAST token, not the first copy // of its text -- "127.0.0.1/32 3" truncated at the '3' of "/32" silently // dropped the whole rule (found live; the digit collision is why the // simpler forbid_site "10.0.0.0/8 4" happened to work and this did not). { struct { const char *in; bool ok; unsigned long want; const char *rest; } aCases[] = { // No threshold: string untouched. { "127.0.0.0/8", true, 0, "127.0.0.0/8" }, { "192.0.2.0 255.255.255.0", true, 0, "192.0.2.0 255.255.255.0" }, // CIDR + threshold. { "127.0.0.0/8 4", true, 4, "127.0.0.0/8" }, // Digit collisions: the threshold digit also appears in the mask. { "127.0.0.1/32 3", true, 3, "127.0.0.1/32" }, { "10.0.0.0/8 8", true, 8, "10.0.0.0/8" }, { "192.0.2.0/24 2", true, 2, "192.0.2.0/24" }, // Address + mask + threshold. { "192.0.2.0 255.255.255.0 8", true, 8, "192.0.2.0 255.255.255.0" }, // Explicit zero disables, same as absent. { "127.0.0.0/8 0", true, 0, "127.0.0.0/8" }, // Negative is a syntax error, not a silent 0. { "127.0.0.0/8 -1", false, 0, nullptr }, // A non-numeric trailing token is not a threshold; leave it be. { "127.0.0.0/8 abc", true, 0, "127.0.0.0/8 abc" }, // IPv6 CIDR. { "2001:db8::/32 5", true, 5, "2001:db8::/32" }, }; for (size_t i = 0; i < sizeof(aCases)/sizeof(aCases[0]); i++) { UTF8 buf[128]; std::strncpy(reinterpret_cast(buf), aCases[i].in, sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; unsigned long ulThreshold = 12345; bool ok = parse_site_threshold(buf, &ulThreshold); char label[192]; snprintf(label, sizeof(label), "parse_site_threshold(\"%s\")", aCases[i].in); if (ok != aCases[i].ok) { g_fail++; printf("FAIL: %s returned %s\n", label, ok ? "true" : "false"); continue; } if (!ok) { g_pass++; continue; } if (ulThreshold != aCases[i].want) { g_fail++; printf("FAIL: %s threshold = %lu want %lu\n", label, ulThreshold, aCases[i].want); continue; } if (0 != strcmp(reinterpret_cast(buf), aCases[i].rest)) { g_fail++; printf("FAIL: %s left \"%s\" want \"%s\"\n", label, reinterpret_cast(buf), aCases[i].rest); continue; } g_pass++; } } printf("\n=== netaddr compare_to: %d passed, %d failed ===\n", g_pass, g_fail); return (g_fail > 0) ? 1 : 0; }