diff --git a/.changeset/lucky-pandas-shave.md b/.changeset/lucky-pandas-shave.md new file mode 100644 index 0000000..108723c --- /dev/null +++ b/.changeset/lucky-pandas-shave.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprototypes": minor +--- + +add EndpointPolicy so a development build can reach a loopback PDS over http diff --git a/.changeset/olive-melons-attend.md b/.changeset/olive-melons-attend.md new file mode 100644 index 0000000..0b51f8d --- /dev/null +++ b/.changeset/olive-melons-attend.md @@ -0,0 +1,5 @@ +--- +"@germ-network/atprototypes": minor +--- + +reject non-https, loopback/private-range, reserved-TLD, and single-label PDS service endpoints diff --git a/Sources/AtprotoTypes/Atproto/DIDDocument.swift b/Sources/AtprotoTypes/Atproto/DIDDocument.swift index 0e5bf9d..bdab061 100644 --- a/Sources/AtprotoTypes/Atproto/DIDDocument.swift +++ b/Sources/AtprotoTypes/Atproto/DIDDocument.swift @@ -41,9 +41,12 @@ extension Atproto { /// /// - Returns: An ``ATService`` item. /// - /// - Throws: ``DIDDocumentError`` if ``service`` is empty or if none of the items - /// contain `#atproto_pds`. - public func checkServiceForAtproto() throws -> Service { + /// - Throws: ``DIDDocumentError`` if ``service`` is empty, if none of the items + /// contain `#atproto_pds`, or if that item's endpoint fails + /// ``Service/validate(endpoint:policy:)``. + public func checkServiceForAtproto( + policy: EndpointPolicy = .default + ) throws -> Service { let services = self.service guard services.count > 0 else { @@ -52,6 +55,8 @@ extension Atproto { for service in services { if service.id == "#atproto_pds" { + try Service.validate( + endpoint: service.serviceEndpoint, policy: policy) return service } } @@ -68,7 +73,7 @@ extension Atproto { } /// Errors relating to the DID Document. - public enum Errors: Error { + public enum Errors: Error, Equatable { /// The ``DIDDocument/service`` array is empty. case emptyArray @@ -79,6 +84,13 @@ extension Atproto { case urlConstructionError case missingServiceUrl + + /// The service endpoint is not `https`. + case insecureServiceUrlScheme(String?) + + /// The service endpoint's host is one we refuse to send traffic to, + /// such as a loopback, link-local, or private-range address. + case disallowedServiceUrlHost(String) } public init( @@ -155,15 +167,20 @@ extension Atproto.DIDDocument { //"The first matching entry in the array should be used, and any others ignored. " //"an account with no valid PDS location in their DID document is broken" public var pdsUrl: URL { - get throws { - guard - let service = service.first(where: { - $0.type == "AtprotoPersonalDataServer" - }) - else { - throw Errors.missingServiceUrl - } - return service.serviceEndpoint + get throws { try pdsUrl(policy: .default) } + } + + ///a property can't take an argument, so anything but ``EndpointPolicy/default`` + ///goes through this spelling + public func pdsUrl(policy: EndpointPolicy) throws -> URL { + guard + let service = service.first(where: { + $0.type == "AtprotoPersonalDataServer" + }) + else { + throw Errors.missingServiceUrl } + try Service.validate(endpoint: service.serviceEndpoint, policy: policy) + return service.serviceEndpoint } } diff --git a/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift b/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift new file mode 100644 index 0000000..92cad0f --- /dev/null +++ b/Sources/AtprotoTypes/Atproto/ServiceEndpoint.swift @@ -0,0 +1,177 @@ +// +// ServiceEndpoint.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 7/25/26. +// + +import Darwin +import Foundation +import Network + +extension Atproto.DIDDocument { + /// What ``Service/validate(endpoint:policy:)`` will accept. Strict unless + /// the caller names otherwise, so nothing relaxes by omission. + public struct EndpointPolicy: Sendable, Equatable { + //no public memberwise init: `.default` and `.developmentLoopback` are + //the only values a consumer outside the package can name + let allowsInsecureLoopback: Bool + + ///https to a public host — the only policy that belongs in a shipped build. + public static let `default` = Self(allowsInsecureLoopback: false) + + ///Additionally accepts http, and only to a loopback host, so a + ///development build can reach a PDS on the developer's own machine: + ///atproto's dev-env serves plain http on `localhost:2583`. The private, + ///CGNAT, link-local, and reserved-TLD rules still apply, so this opens + ///the local machine and not the local network. + public static let developmentLoopback = Self(allowsInsecureLoopback: true) + } +} + +extension Atproto.DIDDocument.Service { + /// A DID document is supplied by a resolver, so its `serviceEndpoint` is + /// attacker-influenced input that we then use as the base URL for both + /// credentialed and public traffic. Reject the schemes and hosts that would + /// turn that into an SSRF primitive. Reach the endpoint through ``pdsUrl`` + /// or ``checkServiceForAtproto(policy:)`` — reading ``service`` directly + /// hands back a URL nothing has screened. + /// + /// This is a name-level contract: it rejects reserved-address literals in + /// every spelling the platform's parsers accept, plus name space that is + /// defined to resolve locally. It does not resolve names, so it cannot see + /// a public name that resolves — or rebinds after any one-shot check — to a + /// reserved address. URLSession offers no hook into the resolution its + /// connections actually use, so that gap is closed elsewhere: the https + /// requirement means a listener at a reserved address must still present a + /// valid certificate for the attacker's chosen name, and the OS + /// local-network entitlement gates connections into private address space. + package static func validate( + endpoint: URL, + policy: Atproto.DIDDocument.EndpointPolicy = .default + ) throws { + let scheme = endpoint.scheme?.lowercased() + //already strips the brackets around an IPv6 literal + let host = endpoint.host(percentEncoded: false) ?? "" + let exempt = policy.allowsInsecureLoopback && loopback(host: host) + + guard scheme == "https" || (exempt && scheme == "http") else { + throw Atproto.DIDDocument.Errors + .insecureServiceUrlScheme(endpoint.scheme) + } + + guard permitted(host: host) || exempt else { + throw Atproto.DIDDocument.Errors.disallowedServiceUrlHost(host) + } + } + + ///the developer's own machine, which is narrower than the private ranges + private static func loopback(host rawHost: String) -> Bool { + let host = normalized(rawHost) + + if host == "localhost" || host.hasSuffix(".localhost") { return true } + + //same discipline as `permitted(host:)`: every parser that can read this + //has to agree, or we don't know where the connection actually lands + let v4Readings = [IPv4Address(host), inetAtonAddress(host)].compactMap { $0 } + if !v4Readings.isEmpty { + return v4Readings.allSatisfy { [UInt8]($0.rawValue).first == 127 } + } + + guard let v6 = IPv6Address(host) else { return false } + if let mapped = v6.asIPv4 { + return [UInt8](mapped.rawValue).first == 127 + } + let bytes = [UInt8](v6.rawValue) + return bytes.count == 16 && bytes.dropLast().allSatisfy { $0 == 0 } + && bytes[15] == 1 + } + + private static func normalized(_ rawHost: String) -> String { + var host = rawHost.lowercased() + //a trailing dot is the same name in FQDN form + if host.hasSuffix(".") { + host.removeLast() + } + //defensive: URL.host() unwraps these, URLComponents.host does not + if host.hasPrefix("["), host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + return host + } + + static func permitted(host rawHost: String) -> Bool { + let host = normalized(rawHost) + + guard !host.isEmpty else { return false } + + //One string can name different addresses depending on who parses it: + //`0177.0.0.1` is 177.0.0.1 to IPv4Address and 127.0.0.1 to inet_aton, + //and `010.0.0.1` splits the other way. We don't control which parser the + //connection ultimately uses, so every reading has to be acceptable. + if let v6 = IPv6Address(host), !permitted(v6) { return false } + if let v4 = IPv4Address(host), !permitted(v4) { return false } + if let legacy = inetAtonAddress(host), !permitted(legacy) { return false } + + //an address literal that survived every parser's screening + if IPv4Address(host) != nil || IPv6Address(host) != nil { return true } + + //single-label names resolve through local search domains, never a public PDS + guard let lastDot = host.lastIndex(of: ".") else { return false } + + return !reservedTLDs.contains(host[host.index(after: lastDot)...]) + } + + //special-use TLDs defined to name local or non-Internet hosts + //(RFC 6761/6762, ICANN `.internal`); same list Bluesky's safe fetch rejects + private static let reservedTLDs: Set = [ + "localhost", "local", "internal", "test", "invalid", "example", + ] + + private static func permitted(_ address: IPv4Address) -> Bool { + let bytes = [UInt8](address.rawValue) + guard bytes.count == 4 else { return false } + + switch bytes[0] { + case 0: return false //0.0.0.0/8 unspecified, "this network" + case 10: return false //10/8 private + case 127: return false //127/8 loopback + case 100: return !(64...127).contains(bytes[1]) //100.64/10 CGNAT + case 169: return bytes[1] != 254 //169.254/16 link-local + case 172: return !(16...31).contains(bytes[1]) //172.16/12 private + case 192: return bytes[1] != 168 //192.168/16 private + case 224...255: return false //224/3 multicast, reserved, broadcast + default: return true + } + } + + private static func permitted(_ address: IPv6Address) -> Bool { + //::ffff:127.0.0.1 and friends are the v4 ranges wearing a v6 hat + if let mapped = address.asIPv4 { + return permitted(mapped) + } + + let bytes = [UInt8](address.rawValue) + guard bytes.count == 16 else { return false } + + //:: unspecified and ::1 loopback + if bytes.dropLast().allSatisfy({ $0 == 0 }), bytes[15] <= 1 { return false } + //fe80::/10 link-local + if bytes[0] == 0xfe, bytes[1] & 0xc0 == 0x80 { return false } + //fc00::/7 unique local + if bytes[0] & 0xfe == 0xfc { return false } + //ff00::/8 multicast + if bytes[0] == 0xff { return false } + + return true + } + + ///the legacy decimal/octal/hex forms `getaddrinfo` still honors + private static func inetAtonAddress(_ host: String) -> IPv4Address? { + var address = in_addr() + guard host.withCString({ inet_aton($0, &address) }) == 1 else { + return nil + } + return IPv4Address(withUnsafeBytes(of: address.s_addr) { Data($0) }) + } +} diff --git a/Tests/AtprotoTypesTests/PDSEndpointTests.swift b/Tests/AtprotoTypesTests/PDSEndpointTests.swift new file mode 100644 index 0000000..9407ecd --- /dev/null +++ b/Tests/AtprotoTypesTests/PDSEndpointTests.swift @@ -0,0 +1,249 @@ +// +// PDSEndpointTests.swift +// AtprotoTypes +// +// Created by Mark @ Germ on 7/25/26. +// + +import AtprotoTypes +import AtprotoTypesMocks +import Foundation +import Testing + +struct PDSEndpointTests { + + private func document(endpoint: String) throws -> Atproto.DIDDocument { + let base = try Atproto.DIDDocument.mock() + let url = try #require(URL(string: endpoint)) + + return .init( + context: base.context, + id: base.id, + alsoKnownAs: base.alsoKnownAs, + verificationMethod: base.verificationMethod, + service: [ + .init( + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: url + ) + ] + ) + } + + @Test( + arguments: [ + "https://blusher.us-east.host.bsky.network", + "https://pds.example.com:8443", + "https://pds.example.com/some/prefix", + //boundaries just outside the blocked ranges + "https://172.32.0.1", + "https://11.0.0.1", + "https://100.128.0.1", + "https://8.8.8.8", + //dotless literals: only the address parsers can vouch for these, + //the single-label rule would otherwise reject them + "https://[2606:4700:4700::1111]", + //1.1.1.1 to every parser we consult + "https://16843009", + ] + ) + func acceptsPublicHttpsEndpoints(_ endpoint: String) throws { + #expect(try document(endpoint: endpoint).pdsUrl.absoluteString == endpoint) + } + + @Test( + arguments: [ + "http://pds.example.com", + "file:///etc/passwd", + "at://example.com", + ] + ) + func rejectsNonHttpsSchemes(_ endpoint: String) throws { + let document = try document(endpoint: endpoint) + let scheme = URL(string: endpoint)?.scheme + + #expect(throws: Atproto.DIDDocument.Errors.insecureServiceUrlScheme(scheme)) { + try document.pdsUrl + } + } + + @Test( + arguments: [ + "https://localhost", + "https://foo.localhost", + "https://localhost.", + "https://LOCALHOST", + "https://127.0.0.1", + "https://[::1]", + "https://10.0.0.5", + "https://192.168.1.1", + "https://172.16.0.1", + "https://169.254.169.254", + "https://100.64.0.1", + "https://[fd00::1]", + "https://[fe80::1]", + "https://[::ffff:127.0.0.1]", + "https://0.0.0.0", + //legacy inet_aton spellings of 127.0.0.1 + "https://2130706433", + "https://0x7f000001", + "https://0177.0.0.1", + ] + ) + func rejectsLoopbackAndPrivateHosts(_ endpoint: String) throws { + let document = try document(endpoint: endpoint) + let host = try #require(URL(string: endpoint)?.host(percentEncoded: false)) + + #expect(throws: Atproto.DIDDocument.Errors.disallowedServiceUrlHost(host)) { + try document.pdsUrl + } + } + + @Test( + arguments: [ + //single-label hosts resolve via local search domains + "https://pds", + "https://intranet", + //special-use TLDs (RFC 6761/6762, ICANN .internal) + "https://foo.local", + "https://foo.internal", + "https://foo.test", + "https://foo.invalid", + "https://foo.example", + "https://FOO.INTERNAL", + "https://foo.local.", + ] + ) + func rejectsReservedNameSpace(_ endpoint: String) throws { + let document = try document(endpoint: endpoint) + let host = try #require(URL(string: endpoint)?.host(percentEncoded: false)) + + #expect(throws: Atproto.DIDDocument.Errors.disallowedServiceUrlHost(host)) { + try document.pdsUrl + } + } + + @Test( + arguments: [ + //the dev-env default + "http://localhost:2583", + "http://127.0.0.1:2583", + "https://localhost", + "https://127.0.0.1", + "https://[::1]", + "http://foo.localhost", + "https://[::ffff:127.0.0.1]", + ] + ) + func developmentLoopbackAcceptsLocalPds(_ endpoint: String) throws { + #expect( + try document(endpoint: endpoint) + .pdsUrl(policy: .developmentLoopback).absoluteString == endpoint + ) + } + + //the hatch opens the local machine, not the local network, and not http at large + @Test( + arguments: [ + //http is forgiven only for loopback + ( + "http://example.com", + Atproto.DIDDocument.Errors.insecureServiceUrlScheme("http") + ), + ( + "http://10.0.0.5", + Atproto.DIDDocument.Errors.insecureServiceUrlScheme("http") + ), + //https to a non-loopback host is exactly as strict as before + ( + "https://10.0.0.5", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost("10.0.0.5") + ), + ( + "https://192.168.1.1", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost("192.168.1.1") + ), + ( + "https://169.254.169.254", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost( + "169.254.169.254") + ), + ( + "https://[fd00::1]", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost("fd00::1") + ), + ( + "https://foo.internal", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost("foo.internal") + ), + ( + "https://pds", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost("pds") + ), + //IPv4Address reads 177.0.0.1 and inet_aton reads 127.0.0.1; a + //disagreement earns no exemption + ( + "https://0177.0.0.1", + Atproto.DIDDocument.Errors.disallowedServiceUrlHost("0177.0.0.1") + ), + ] + ) + func developmentLoopbackStillRejectsEverythingElse( + _ endpoint: String, + _ expected: Atproto.DIDDocument.Errors + ) throws { + let document = try document(endpoint: endpoint) + + #expect(throws: expected) { + try document.pdsUrl(policy: .developmentLoopback) + } + } + + @Test func defaultPolicyStillRejectsLoopback() throws { + let document = try document(endpoint: "http://localhost:2583") + + #expect(throws: Atproto.DIDDocument.Errors.insecureServiceUrlScheme("http")) { + try document.pdsUrl(policy: .default) + } + #expect(throws: Atproto.DIDDocument.Errors.insecureServiceUrlScheme("http")) { + try document.pdsUrl + } + } + + @Test func checkServiceForAtprotoHonorsPolicy() throws { + let document = try document(endpoint: "http://localhost:2583") + + #expect( + try document.checkServiceForAtproto(policy: .developmentLoopback) + .serviceEndpoint.host() == "localhost" + ) + #expect(throws: Atproto.DIDDocument.Errors.insecureServiceUrlScheme("http")) { + try document.checkServiceForAtproto() + } + } + + @Test func checkServiceForAtprotoRejectsDisallowedEndpoint() throws { + let document = try document(endpoint: "https://127.0.0.1") + + #expect(throws: Atproto.DIDDocument.Errors.disallowedServiceUrlHost("127.0.0.1")) { + try document.checkServiceForAtproto() + } + } + + @Test func checkServiceForAtprotoAcceptsPublicEndpoint() throws { + let document = try document(endpoint: "https://pds.example.com") + + #expect( + try document.checkServiceForAtproto().serviceEndpoint.host() + == "pds.example.com") + } + + //the mock is the fixture every other suite builds on, so it has to stay valid + @Test func mockDocumentResolves() throws { + #expect( + try Atproto.DIDDocument.mock().pdsUrl + == URL(string: "https://blusher.us-east.host.bsky.network") + ) + } +}