@@ -102,94 +102,6 @@ class MapDataManager: ObservableObject {
102102 return metadata
103103 }
104104
105- /// Downloads an `http`/`https` GeoJSON overlay from `urlString` and imports it through the same
106- /// pipeline as `processUploadedFile`. Lets an overlay be fetched and installed without the
107- /// file-picker UI — e.g. from the `importGeoJSON` deep link (see `Router.swift`).
108- ///
109- /// Because the deep-link caller is untrusted, this rejects any non-`http(s)` scheme and blocks
110- /// loopback / link-local / private / reserved hosts (SSRF hardening). Local `file://`
111- /// imports go through `processUploadedFile` directly, not this path.
112- func importFromRemote( urlString: String , configuration injectedConfiguration: URLSessionConfiguration ? = nil ) async throws -> MapDataMetadata {
113- guard let url = URL ( string: urlString) else {
114- throw MapDataError . invalidDestination
115- }
116-
117- // This entry point is reachable from the `importGeoJSON` deep link with an
118- // attacker-controlled URL. Only allow `http(s)` — reject everything else (closing the previous
119- // `file://` fallthrough that let a deep link reach the local-file read pipeline).
120- guard url. scheme == " http " || url. scheme == " https " else {
121- throw MapDataError . disallowedHost
122- }
123-
124- // Block SSRF to loopback / link-local / private / reserved hosts. Resolve
125- // the host and reject if any resolved address falls in a non-routable / internal range, so an
126- // attacker deep link cannot probe localhost services, LAN admin pages, or cloud metadata
127- // (169.254.169.254) from the victim's network position.
128- guard let host = url. host, !host. isEmpty else {
129- throw MapDataError . disallowedHost
130- }
131- guard !Self. isDisallowedHost ( host) else {
132- throw MapDataError . disallowedHost
133- }
134-
135- // The up-front host check above can be defeated by two SSRF tricks that both re-enter DNS/URL
136- // handling *after* validation: (1) an HTTP redirect to an internal host, and (2) DNS rebinding
137- // (a short-TTL record that answers with a public IP at validation time and an internal IP when
138- // `URLSession` re-resolves at connect time). The session is always backed by `SSRFGuardDelegate`:
139- // redirect re-validation is the reliable control; the connected-peer check is opportunistic (see
140- // below). Tests inject only a `URLSessionConfiguration` (e.g. a `URLProtocol` stub), so a caller
141- // can never supply a session that bypasses the guard.
142- let guardDelegate = SSRFGuardDelegate ( )
143- let session = URLSession (
144- configuration: injectedConfiguration ?? . ephemeral,
145- delegate: guardDelegate,
146- delegateQueue: nil
147- )
148- defer { session. finishTasksAndInvalidate ( ) }
149-
150- // Stream the body instead of buffering it whole. `data(from:)` would materialise the entire
151- // (possibly attacker-sized or endless) response in memory before the size guard could run, so we
152- // read the async byte stream and abort the moment the running total exceeds `maxFileSize`.
153- let ( byteStream, response) = try await session. bytes ( from: url)
154-
155- // Cheap early reject when the server honestly declares an oversized body.
156- if response. expectedContentLength > maxFileSize {
157- throw MapDataError . fileTooLarge
158- }
159- if let httpResponse = response as? HTTPURLResponse , !( 200 ..< 300 ) . contains ( httpResponse. statusCode) {
160- throw MapDataError . invalidContent
161- }
162-
163- var data = Data ( )
164- if response. expectedContentLength > 0 {
165- data. reserveCapacity ( Int ( min ( response. expectedContentLength, maxFileSize) ) )
166- }
167- for try await byte in byteStream {
168- data. append ( byte)
169- if data. count > maxFileSize {
170- throw MapDataError . fileTooLarge
171- }
172- }
173-
174- // Opportunistic DNS-rebinding catch: transaction metrics are collected once the body finishes, so
175- // by the time the stream is drained the guard has seen the real peer address. If the connection
176- // landed on an internal peer, discard the response so its content is never imported/rendered —
177- // redirect re-validation remains the reliable control (`URLSession` exposes no pre-connect IP hook).
178- if guardDelegate. connectedToDisallowedPeer {
179- throw MapDataError . disallowedHost
180- }
181-
182- let suggestedName = url. pathExtension. lowercased ( ) == " geojson " || url. pathExtension. lowercased ( ) == " json "
183- ? url. lastPathComponent
184- : " coverage.geojson "
185- let tempURL = FileManager . default. temporaryDirectory. appendingPathComponent ( UUID ( ) . uuidString) . appendingPathComponent ( suggestedName)
186- try FileManager . default. createDirectory ( at: tempURL. deletingLastPathComponent ( ) , withIntermediateDirectories: true )
187- try data. write ( to: tempURL)
188- defer { try ? FileManager . default. removeItem ( at: tempURL) }
189-
190- return try await processUploadedFile ( from: tempURL)
191- }
192-
193105 /// Imports an in-memory GeoJSON string (e.g. the coverage `FeatureCollection` handed back by
194106 /// the Site Planner's native bridge) through the same pipeline as `processUploadedFile`, so it
195107 /// reuses the exact validation + render + styling path with no round-trip to the share sheet.
@@ -512,192 +424,6 @@ class MapDataManager: ObservableObject {
512424 }
513425}
514426
515- // MARK: - SSRF host validation
516-
517- extension MapDataManager {
518-
519- /// Returns `true` if `host` is an internal / non-routable destination that must not be fetched from
520- /// an attacker-supplied deep link. Resolves the host and rejects if it is `localhost`, an mDNS
521- /// `.local` name, or resolves to any loopback / link-local / private / reserved IP range.
522- static func isDisallowedHost( _ host: String ) -> Bool {
523- let lowered = host. lowercased ( )
524- // Reject bare-name internal hosts outright (localhost + mDNS `.local`).
525- if lowered == " localhost " || lowered. hasSuffix ( " .local " ) {
526- return true
527- }
528-
529- // A URL host may be an IPv6 literal wrapped in brackets — strip them before resolving.
530- let bareHost = lowered. hasPrefix ( " [ " ) && lowered. hasSuffix ( " ] " )
531- ? String ( lowered. dropFirst ( ) . dropLast ( ) )
532- : lowered
533-
534- // Resolve the host (handles both literal IPs and DNS names, closing the hostname→private-IP gap).
535- var hints = addrinfo (
536- ai_flags: 0 ,
537- ai_family: AF_UNSPEC,
538- ai_socktype: SOCK_STREAM,
539- ai_protocol: 0 ,
540- ai_addrlen: 0 ,
541- ai_canonname: nil ,
542- ai_addr: nil ,
543- ai_next: nil
544- )
545- var result : UnsafeMutablePointer < addrinfo > ?
546- guard getaddrinfo ( bareHost, nil , & hints, & result) == 0 , let first = result else {
547- // Resolution failed — fail closed and treat as disallowed.
548- return true
549- }
550- defer { freeaddrinfo ( result) }
551-
552- var addr : UnsafeMutablePointer < addrinfo > ? = first
553- while let current = addr {
554- if let sockaddr = current. pointee. ai_addr {
555- if current. pointee. ai_family == AF_INET {
556- let ipv4 = sockaddr. withMemoryRebound ( to: sockaddr_in. self, capacity: 1 ) {
557- UInt32 ( bigEndian: $0. pointee. sin_addr. s_addr)
558- }
559- if isDisallowedIPv4 ( ipv4) { return true }
560- } else if current. pointee. ai_family == AF_INET6 {
561- var bytes = [ UInt8] ( repeating: 0 , count: 16 )
562- sockaddr. withMemoryRebound ( to: sockaddr_in6. self, capacity: 1 ) {
563- withUnsafeBytes ( of: $0. pointee. sin6_addr) { raw in
564- for i in 0 ..< 16 { bytes [ i] = raw [ i] }
565- }
566- }
567- if isDisallowedIPv6 ( bytes) { return true }
568- }
569- }
570- addr = current. pointee. ai_next
571- }
572- return false
573- }
574-
575- /// `ip` is a host-order IPv4 address. Blocks loopback, private (RFC 1918), link-local,
576- /// CGNAT, "this host", benchmarking, and other reserved ranges.
577- private static func isDisallowedIPv4( _ ip: UInt32 ) -> Bool {
578- func octets( _ a: UInt8 , _ b: UInt8 , _ c: UInt8 , _ d: UInt8 ) -> UInt32 {
579- ( UInt32 ( a) << 24 ) | ( UInt32 ( b) << 16 ) | ( UInt32 ( c) << 8 ) | UInt32 ( d)
580- }
581- func inRange( _ base: UInt32 , _ prefix: Int ) -> Bool {
582- let mask : UInt32 = prefix == 0 ? 0 : ~ UInt32( 0 ) << ( 32 - prefix)
583- return ( ip & mask) == ( base & mask)
584- }
585- return inRange ( octets ( 0 , 0 , 0 , 0 ) , 8 ) // 0.0.0.0/8 "this host"
586- || inRange ( octets ( 10 , 0 , 0 , 0 ) , 8 ) // 10.0.0.0/8 private
587- || inRange ( octets ( 100 , 64 , 0 , 0 ) , 10 ) // 100.64.0.0/10 CGNAT
588- || inRange ( octets ( 127 , 0 , 0 , 0 ) , 8 ) // 127.0.0.0/8 loopback
589- || inRange ( octets ( 169 , 254 , 0 , 0 ) , 16 ) // 169.254.0.0/16 link-local (inc. 169.254.169.254)
590- || inRange ( octets ( 172 , 16 , 0 , 0 ) , 12 ) // 172.16.0.0/12 private
591- || inRange ( octets ( 192 , 0 , 0 , 0 ) , 24 ) // 192.0.0.0/24 IETF protocol assignments
592- || inRange ( octets ( 192 , 0 , 2 , 0 ) , 24 ) // 192.0.2.0/24 TEST-NET-1 (RFC 5737)
593- || inRange ( octets ( 192 , 168 , 0 , 0 ) , 16 ) // 192.168.0.0/16 private
594- || inRange ( octets ( 198 , 18 , 0 , 0 ) , 15 ) // 198.18.0.0/15 benchmarking
595- || inRange ( octets ( 198 , 51 , 100 , 0 ) , 24 ) // 198.51.100.0/24 TEST-NET-2 (RFC 5737)
596- || inRange ( octets ( 203 , 0 , 113 , 0 ) , 24 ) // 203.0.113.0/24 TEST-NET-3 (RFC 5737)
597- || inRange ( octets ( 224 , 0 , 0 , 0 ) , 4 ) // 224.0.0.0/4 multicast
598- || inRange ( octets ( 240 , 0 , 0 , 0 ) , 4 ) // 240.0.0.0/4 reserved
599- || inRange ( octets ( 255 , 255 , 255 , 255 ) , 32 ) // 255.255.255.255/32 limited broadcast
600- }
601-
602- /// `bytes` is a 16-byte IPv6 address. Blocks loopback (`::1`), unspecified (`::`), link-local
603- /// (`fe80::/10`), unique-local (`fc00::/7`), deprecated site-local (`fec0::/10`), multicast
604- /// (`ff00::/8`), and IPv4-mapped addresses in a blocked v4 range.
605- private static func isDisallowedIPv6( _ bytes: [ UInt8 ] ) -> Bool {
606- guard bytes. count == 16 else { return true }
607- // Loopback ::1
608- if bytes [ 0 ..< 15 ] . allSatisfy ( { $0 == 0 } ) && bytes [ 15 ] == 1 { return true }
609- // Unspecified ::
610- if bytes. allSatisfy ( { $0 == 0 } ) { return true }
611- // Link-local fe80::/10
612- if bytes [ 0 ] == 0xfe && ( bytes [ 1 ] & 0xc0 ) == 0x80 { return true }
613- // Unique-local fc00::/7
614- if ( bytes [ 0 ] & 0xfe ) == 0xfc { return true }
615- // Deprecated site-local fec0::/10 (RFC 3879)
616- if bytes [ 0 ] == 0xfe && ( bytes [ 1 ] & 0xc0 ) == 0xc0 { return true }
617- // Multicast ff00::/8
618- if bytes [ 0 ] == 0xff { return true }
619- // IPv4-mapped ::ffff:a.b.c.d — re-check the embedded v4 address.
620- if bytes [ 0 ..< 10 ] . allSatisfy ( { $0 == 0 } ) && bytes [ 10 ] == 0xff && bytes [ 11 ] == 0xff {
621- let ipv4 = ( UInt32 ( bytes [ 12 ] ) << 24 ) | ( UInt32 ( bytes [ 13 ] ) << 16 ) | ( UInt32 ( bytes [ 14 ] ) << 8 ) | UInt32 ( bytes [ 15 ] )
622- return isDisallowedIPv4 ( ipv4)
623- }
624- // NAT64 well-known prefix 64:ff9b::/96 — the last 4 bytes embed the real IPv4 target, so on a
625- // NAT64 network `64:ff9b::a9fe:a9fe` would otherwise reach 169.254.169.254. Re-check the embed.
626- if bytes [ 0 ] == 0x00 && bytes [ 1 ] == 0x64 && bytes [ 2 ] == 0xff && bytes [ 3 ] == 0x9b
627- && bytes [ 4 ..< 12 ] . allSatisfy ( { $0 == 0 } ) {
628- let ipv4 = ( UInt32 ( bytes [ 12 ] ) << 24 ) | ( UInt32 ( bytes [ 13 ] ) << 16 ) | ( UInt32 ( bytes [ 14 ] ) << 8 ) | UInt32 ( bytes [ 15 ] )
629- return isDisallowedIPv4 ( ipv4)
630- }
631- return false
632- }
633- }
634-
635- // MARK: - SSRF-guarded URLSession delegate
636-
637- /// `URLSession` delegate that hardens `MapDataManager.importFromRemote` against the two SSRF tricks
638- /// the up-front hostname check can't catch on its own:
639- ///
640- /// - **HTTP redirects** — `willPerformHTTPRedirection` re-runs `MapDataManager.isDisallowedHost` on
641- /// every hop and refuses to follow a redirect to an internal host, so a public allow-listed URL
642- /// can't `302` the request onto `169.254.169.254`, `localhost`, or a LAN address. This is the
643- /// primary, reliable control.
644- /// - **DNS rebinding** — `URLSession` re-resolves DNS itself at connect time, so a short-TTL record
645- /// can pass validation with a public IP yet connect to an internal one. `didFinishCollecting`
646- /// reads the real peer address from the transaction metrics and flags it; the caller then discards
647- /// the response. This is best-effort defense-in-depth (metrics-based, so it prevents the fetched
648- /// content from being imported rather than blocking the initial GET, which `URLSession` gives no
649- /// pre-connect hook to stop).
650- // Internal (not private) so `SSRFGuardDelegateTests` can drive the redirect re-validation directly.
651- final class SSRFGuardDelegate : NSObject , URLSessionTaskDelegate , @unchecked Sendable {
652- private let lock = NSLock ( )
653- private var disallowedPeer = false
654-
655- /// `true` once any transaction in the task connected to an internal / non-routable peer.
656- var connectedToDisallowedPeer : Bool {
657- lock. lock ( ) ; defer { lock. unlock ( ) }
658- return disallowedPeer
659- }
660-
661- private func flagDisallowedPeer( ) {
662- lock. lock ( ) ; defer { lock. unlock ( ) }
663- disallowedPeer = true
664- }
665-
666- func urlSession(
667- _ session: URLSession ,
668- task: URLSessionTask ,
669- willPerformHTTPRedirection response: HTTPURLResponse ,
670- newRequest request: URLRequest ,
671- completionHandler: @escaping ( URLRequest ? ) -> Void
672- ) {
673- guard let host = request. url? . host, !host. isEmpty, !MapDataManager. isDisallowedHost ( host) else {
674- // Disallowed or unparseable redirect target — do not follow it. Passing `nil` returns the
675- // redirect response itself, which the caller treats as a non-2xx failure.
676- completionHandler ( nil )
677- return
678- }
679- completionHandler ( request)
680- }
681-
682- func urlSession(
683- _ session: URLSession ,
684- task: URLSessionTask ,
685- didFinishCollecting metrics: URLSessionTaskMetrics
686- ) {
687- for transaction in metrics. transactionMetrics {
688- // When the connection is proxied (e.g. iCloud Private Relay), `remoteAddress` is the proxy's IP,
689- // not the origin's — checking it would give a false internal / rebinding verdict, so skip it.
690- if transaction. isProxyConnection { continue }
691- guard let peer = transaction. remoteAddress, !peer. isEmpty else { continue }
692- // `remoteAddress` is a literal IP; `isDisallowedHost` resolves literals locally (no network).
693- if MapDataManager . isDisallowedHost ( peer) {
694- flagDisallowedPeer ( )
695- return
696- }
697- }
698- }
699- }
700-
701427// MARK: - Supporting Types
702428
703429/// Metadata for uploaded map data files
@@ -744,7 +470,6 @@ enum MapDataError: Error, LocalizedError {
744470 case invalidDestination
745471 case fileNotFound
746472 case saveFailed
747- case disallowedHost
748473
749474 var errorDescription : String ? {
750475 switch self {
@@ -764,8 +489,6 @@ enum MapDataError: Error, LocalizedError {
764489 return " File not found. "
765490 case . saveFailed:
766491 return " Failed to save file. "
767- case . disallowedHost:
768- return String ( localized: " This URL is not allowed. " )
769492 }
770493 }
771494}
0 commit comments