From ec470da8b3356b445ed48f94fcb18c365c320666 Mon Sep 17 00:00:00 2001 From: Keely Podosin Date: Thu, 1 Oct 2026 12:52:30 -0700 Subject: [PATCH] Endpoint: make matchesPattern stricter about pattern types --- .../Utilities/IPAddress+CIDR.swift | 117 +++++++++++++++--- .../SwiftNetworkCIDRTests.swift | 62 ++++++++++ 2 files changed, 164 insertions(+), 15 deletions(-) diff --git a/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift b/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift index d72564ef..90038696 100644 --- a/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift +++ b/Sources/SwiftNetwork/Utilities/IPAddress+CIDR.swift @@ -127,6 +127,11 @@ extension IPv4Address { } return matchesDomainPattern(debugDescription, pattern: pattern) } + + /// Whether this address falls inside an already-parsed IPv4 CIDR block. + func matchesCIDR(network: UInt32, mask: UInt32) -> Bool { + (addressValue & mask) == network + } } @available(Network 0.1.0, *) @@ -147,32 +152,114 @@ extension IPv6Address { /// representation matches `pattern` as a domain pattern. func matches(pattern: String) -> Bool { if let cidr = parseCIDRv6(pattern) { - let (a0, a1, a2, a3) = addressValue - let (n0, n1, n2, n3) = cidr.network - let (m0, m1, m2, m3) = cidr.mask - return (a0 & m0) == n0 && (a1 & m1) == n1 && (a2 & m2) == n2 && (a3 & m3) == n3 + return matchesCIDR(network: cidr.network, mask: cidr.mask) } return matchesDomainPattern(debugDescription, pattern: pattern) } + + /// Whether this address falls inside an already-parsed IPv6 CIDR block. + func matchesCIDR( + network: (UInt32, UInt32, UInt32, UInt32), + mask: (UInt32, UInt32, UInt32, UInt32) + ) -> Bool { + let (a0, a1, a2, a3) = addressValue + let (n0, n1, n2, n3) = network + let (m0, m1, m2, m3) = mask + return (a0 & m0) == n0 && (a1 & m1) == n1 && (a2 & m2) == n2 && (a3 & m3) == n3 + } +} + +// MARK: - Endpoint pattern matching + +/// How a proxy-exception pattern should be interpreted, decided by the pattern's own shape. +/// Patterns are tried in this order: wildcard, address literal, CIDR, domain. +@available(Network 0.1.0, *) +private enum ProxyPatternKind { + case wildcard + case v4Literal(IPv4Address) + case v6Literal(IPv6Address) + case v4CIDR(network: UInt32, mask: UInt32) + case v6CIDR(network: (UInt32, UInt32, UInt32, UInt32), mask: (UInt32, UInt32, UInt32, UInt32)) + case domain + + init(_ pattern: String) { + if pattern == "*" { + self = .wildcard + } else if let v4 = IPv4Address(pattern) { + self = .v4Literal(v4) + } else if let v6 = IPv6Address(pattern) { + self = .v6Literal(v6) + } else if let cidr = parseCIDRv4(pattern) { + self = .v4CIDR(network: cidr.network, mask: cidr.mask) + } else if let cidr = parseCIDRv6(pattern) { + self = .v6CIDR(network: cidr.network, mask: cidr.mask) + } else { + self = .domain + } + } } @available(Network 0.1.0, *) extension Endpoint { - /// Returns true if this endpoint matches `pattern`. `"*"` matches all endpoints. Host endpoints - /// are matched by hostname; address endpoints are matched by IP address or CIDR block. + /// Returns true if this endpoint matches `pattern`. The pattern's form decides how they're compared. + /// + /// - Address and CIDR patterns only match address endpoints, so `"1.2.3.4"` doesn't match a + /// host named `1.2.3.4`. + /// - Addresses are compared by value, so different spellings of the same IPv6 address match. + /// - Wildcard text matching applies to IPv4 addresses only, so `"2001:db8:*"` matches nothing. func matchesPattern(_ pattern: String) -> Bool { - if pattern == "*" { return true } + // Only host and address endpoints can match, and that's checked before the pattern, + // so even "*" does not match a bonjour, URL, or service endpoint. + let addressEndpoint: AddressEndpoint? switch type { - case .host(let hostEndpoint): - return matchesDomainPattern(hostEndpoint.name, pattern: pattern) - case .address(let addressEndpoint): - switch addressEndpoint.type { - case .v4(let ipv4, _): return ipv4.matches(pattern: pattern) - case .v6(let ipv6, _): return ipv6.matches(pattern: pattern) - default: return false - } + case .address(let endpoint): + addressEndpoint = endpoint + case .host: + addressEndpoint = nil default: return false } + + switch ProxyPatternKind(pattern) { + case .wildcard: + return true + + case .v4Literal(let patternAddress): + guard let addressEndpoint, case .v4(let ipv4, _) = addressEndpoint.type else { return false } + return ipv4.addressValue == patternAddress.addressValue + + case .v6Literal(let patternAddress): + guard let addressEndpoint, case .v6(let ipv6, _) = addressEndpoint.type else { return false } + // The scope must match too, and a bare literal carries no scope. + return ipv6.addressValue == patternAddress.addressValue && addressEndpoint.scope == 0 + + case .v4CIDR(let network, let mask): + guard let addressEndpoint, case .v4(let ipv4, _) = addressEndpoint.type else { return false } + return ipv4.matchesCIDR(network: network, mask: mask) + + case .v6CIDR(let network, let mask): + guard let addressEndpoint, case .v6(let ipv6, _) = addressEndpoint.type else { return false } + return ipv6.matchesCIDR(network: network, mask: mask) + + case .domain: + if case .host(let hostEndpoint) = type { + return matchesDomainPattern(hostEndpoint.name, pattern: pattern) + } + guard let addressEndpoint else { return false } + #if NETWORK_PRIVATE + // An address resolved from a hostname carries that name as its policy domain, and + // domain patterns match against it. + if let policyDomain = domainForPolicy, + matchesDomainPattern(policyDomain, pattern: pattern) + { + return true + } + #endif + // IPv4 only, so that wildcard forms like "17.42.*.10" still work. + if case .v4(let ipv4, _) = addressEndpoint.type { + return matchesDomainPattern(ipv4.debugDescription, pattern: pattern) + } + return false + } } } diff --git a/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift index eef5ea8a..f9178ddc 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkCIDRTests.swift @@ -309,4 +309,66 @@ final class SwiftNetworkCIDRTests: NetTestCase { XCTAssertTrue(ep.matchesPattern("2001:db8::/32")) XCTAssertFalse(ep.matchesPattern("2001:db9::/32")) } + + // MARK: - Endpoint.matchesPattern + + func testEndpointMatchesPattern_ipv6LiteralExact() { + let ep = Endpoint(address: IPv6Address.loopback, port: 443) + XCTAssertTrue(ep.matchesPattern("::1")) + XCTAssertFalse(ep.matchesPattern("::2")) + } + + func testEndpointMatchesPattern_ipv6LiteralNonCanonicalSpellingStillMatches() { + // One IPv6 address has many spellings, and an exception list may + // use any of them. + let ep = Endpoint(address: IPv6Address.loopback, port: 443) + XCTAssertTrue(ep.matchesPattern("0:0:0:0:0:0:0:1")) + XCTAssertTrue(ep.matchesPattern("0000:0000:0000:0000:0000:0000:0000:0001")) + } + + func testEndpointMatchesPattern_ipv6WildcardPatternRefused() { + // Refuse domain-style matching for IPv6 + let addr = IPv6Address([ + 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + ])! + let ep = Endpoint(address: addr, port: 443) + XCTAssertFalse(ep.matchesPattern("2001:db8:*")) + } + + func testEndpointMatchesPattern_ipv4WildcardInAddressTextStillWorks() { + // IPv4 keeps it, which is what makes "17.42.*.10" style entries work. + let ep = Endpoint(address: IPv4Address([17, 42, 9, 10])!, port: 443) + XCTAssertTrue(ep.matchesPattern("17.42.*.10")) + XCTAssertFalse(ep.matchesPattern("17.43.*.10")) + } + + func testEndpointMatchesPattern_addressLiteralDoesNotMatchHostEndpoint() { + // An address-literal pattern must not fall through to domain matching. + let ep = Endpoint(hostname: "1.2.3.4", port: 80) + XCTAssertFalse(ep.matchesPattern("1.2.3.4")) + } + + func testEndpointMatchesPattern_cidrDoesNotMatchHostEndpoint() { + let ep = Endpoint(hostname: "example.com", port: 80) + XCTAssertFalse(ep.matchesPattern("192.168.1.0/24")) + XCTAssertFalse(ep.matchesPattern("2001:db8::/32")) + } + + func testEndpointMatchesPattern_ipv4LiteralDoesNotMatchIPv6Endpoint() { + let ep = Endpoint(address: IPv6Address.loopback, port: 443) + XCTAssertFalse(ep.matchesPattern("1.2.3.4")) + } + + func testEndpointMatchesPattern_unsupportedEndpointTypeNeverMatches() { + // Reject anything that is not a host or address before reading the pattern, so + // even the wildcard does not match. + guard let urlEndpoint = URLEndpoint(url: URL(string: "https://example.com/x")!) else { + XCTFail("could not build URL endpoint") + return + } + let ep = Endpoint(urlEndpoint) + XCTAssertFalse(ep.matchesPattern("*")) + XCTAssertFalse(ep.matchesPattern("example.com")) + } }