@@ -102,20 +102,69 @@ class MapDataManager: ObservableObject {
102102 return metadata
103103 }
104104
105- /// Downloads (`http`/`https`) or reads (`file`) a GeoJSON overlay from `urlString` and imports it
106- /// through the same pipeline as `processUploadedFile`. Lets an overlay be fetched and installed
107- /// without the file-picker UI — e.g. from the `importGeoJSON` deep link (see `Router.swift`).
108- func importFromRemote( urlString: String , session: URLSession = . shared) async throws -> MapDataMetadata {
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 , session injectedSession: URLSession ? = nil ) async throws -> MapDataMetadata {
109113 guard let url = URL ( string: urlString) else {
110114 throw MapDataError . invalidDestination
111115 }
112116
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).
113120 guard url. scheme == " http " || url. scheme == " https " else {
114- // Already local (e.g. `file://`) — hand straight to the existing pipeline.
115- return try await processUploadedFile ( from: url)
116- }
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). When the caller doesn't inject a session (tests do),
139+ // build one backed by `SSRFGuardDelegate`: redirect re-validation is the reliable control; the
140+ // connected-peer check is opportunistic (see below).
141+ //
142+ // WARNING: an *injected* session bypasses `SSRFGuardDelegate` entirely (redirect + peer checks).
143+ // Injection exists only for the test stub, which uses fixed public hostnames; never route
144+ // untrusted production URLs through a caller-supplied session.
145+ let guardDelegate = SSRFGuardDelegate ( )
146+ let session : URLSession
147+ let ownsSession : Bool
148+ if let injectedSession {
149+ session = injectedSession
150+ ownsSession = false
151+ } else {
152+ session = URLSession ( configuration: . ephemeral, delegate: guardDelegate, delegateQueue: nil )
153+ ownsSession = true
154+ }
155+ defer { if ownsSession { session. finishTasksAndInvalidate ( ) } }
117156
118157 let ( data, response) = try await session. data ( from: url)
158+
159+ // Opportunistic DNS-rebinding catch: if the connection landed on an internal peer, discard the
160+ // response so its content is never imported/rendered. This only fires when `didFinishCollecting`
161+ // wins the race with the `data(from:)` continuation, so it catches a subset of rebinding cases
162+ // rather than all of them — redirect re-validation remains the reliable control. `URLSession`
163+ // exposes no pre-connect IP hook, so the initial GET can't be blocked outright here.
164+ if guardDelegate. connectedToDisallowedPeer {
165+ throw MapDataError . disallowedHost
166+ }
167+
119168 if let httpResponse = response as? HTTPURLResponse , !( 200 ..< 300 ) . contains ( httpResponse. statusCode) {
120169 throw MapDataError . invalidContent
121170 }
@@ -456,6 +505,183 @@ class MapDataManager: ObservableObject {
456505 }
457506}
458507
508+ // MARK: - SSRF host validation
509+
510+ extension MapDataManager {
511+
512+ /// Returns `true` if `host` is an internal / non-routable destination that must not be fetched from
513+ /// an attacker-supplied deep link. Resolves the host and rejects if it is `localhost`, an mDNS
514+ /// `.local` name, or resolves to any loopback / link-local / private / reserved IP range.
515+ static func isDisallowedHost( _ host: String ) -> Bool {
516+ let lowered = host. lowercased ( )
517+ // Reject bare-name internal hosts outright (localhost + mDNS `.local`).
518+ if lowered == " localhost " || lowered. hasSuffix ( " .local " ) {
519+ return true
520+ }
521+
522+ // A URL host may be an IPv6 literal wrapped in brackets — strip them before resolving.
523+ let bareHost = lowered. hasPrefix ( " [ " ) && lowered. hasSuffix ( " ] " )
524+ ? String ( lowered. dropFirst ( ) . dropLast ( ) )
525+ : lowered
526+
527+ // Resolve the host (handles both literal IPs and DNS names, closing the hostname→private-IP gap).
528+ var hints = addrinfo (
529+ ai_flags: 0 ,
530+ ai_family: AF_UNSPEC,
531+ ai_socktype: SOCK_STREAM,
532+ ai_protocol: 0 ,
533+ ai_addrlen: 0 ,
534+ ai_canonname: nil ,
535+ ai_addr: nil ,
536+ ai_next: nil
537+ )
538+ var result : UnsafeMutablePointer < addrinfo > ?
539+ guard getaddrinfo ( bareHost, nil , & hints, & result) == 0 , let first = result else {
540+ // Resolution failed — fail closed and treat as disallowed.
541+ return true
542+ }
543+ defer { freeaddrinfo ( result) }
544+
545+ var addr : UnsafeMutablePointer < addrinfo > ? = first
546+ while let current = addr {
547+ if let sockaddr = current. pointee. ai_addr {
548+ if current. pointee. ai_family == AF_INET {
549+ let ipv4 = sockaddr. withMemoryRebound ( to: sockaddr_in. self, capacity: 1 ) {
550+ UInt32 ( bigEndian: $0. pointee. sin_addr. s_addr)
551+ }
552+ if isDisallowedIPv4 ( ipv4) { return true }
553+ } else if current. pointee. ai_family == AF_INET6 {
554+ var bytes = [ UInt8] ( repeating: 0 , count: 16 )
555+ sockaddr. withMemoryRebound ( to: sockaddr_in6. self, capacity: 1 ) {
556+ withUnsafeBytes ( of: $0. pointee. sin6_addr) { raw in
557+ for i in 0 ..< 16 { bytes [ i] = raw [ i] }
558+ }
559+ }
560+ if isDisallowedIPv6 ( bytes) { return true }
561+ }
562+ }
563+ addr = current. pointee. ai_next
564+ }
565+ return false
566+ }
567+
568+ /// `ip` is a host-order IPv4 address. Blocks loopback, private (RFC 1918), link-local,
569+ /// CGNAT, "this host", benchmarking, and other reserved ranges.
570+ private static func isDisallowedIPv4( _ ip: UInt32 ) -> Bool {
571+ func octets( _ a: UInt8 , _ b: UInt8 , _ c: UInt8 , _ d: UInt8 ) -> UInt32 {
572+ ( UInt32 ( a) << 24 ) | ( UInt32 ( b) << 16 ) | ( UInt32 ( c) << 8 ) | UInt32 ( d)
573+ }
574+ func inRange( _ base: UInt32 , _ prefix: Int ) -> Bool {
575+ let mask : UInt32 = prefix == 0 ? 0 : ~ UInt32( 0 ) << ( 32 - prefix)
576+ return ( ip & mask) == ( base & mask)
577+ }
578+ return inRange ( octets ( 0 , 0 , 0 , 0 ) , 8 ) // 0.0.0.0/8 "this host"
579+ || inRange ( octets ( 10 , 0 , 0 , 0 ) , 8 ) // 10.0.0.0/8 private
580+ || inRange ( octets ( 100 , 64 , 0 , 0 ) , 10 ) // 100.64.0.0/10 CGNAT
581+ || inRange ( octets ( 127 , 0 , 0 , 0 ) , 8 ) // 127.0.0.0/8 loopback
582+ || inRange ( octets ( 169 , 254 , 0 , 0 ) , 16 ) // 169.254.0.0/16 link-local (inc. 169.254.169.254)
583+ || inRange ( octets ( 172 , 16 , 0 , 0 ) , 12 ) // 172.16.0.0/12 private
584+ || inRange ( octets ( 192 , 0 , 0 , 0 ) , 24 ) // 192.0.0.0/24 IETF protocol assignments
585+ || inRange ( octets ( 192 , 0 , 2 , 0 ) , 24 ) // 192.0.2.0/24 TEST-NET-1 (RFC 5737)
586+ || inRange ( octets ( 192 , 168 , 0 , 0 ) , 16 ) // 192.168.0.0/16 private
587+ || inRange ( octets ( 198 , 18 , 0 , 0 ) , 15 ) // 198.18.0.0/15 benchmarking
588+ || inRange ( octets ( 198 , 51 , 100 , 0 ) , 24 ) // 198.51.100.0/24 TEST-NET-2 (RFC 5737)
589+ || inRange ( octets ( 203 , 0 , 113 , 0 ) , 24 ) // 203.0.113.0/24 TEST-NET-3 (RFC 5737)
590+ || inRange ( octets ( 224 , 0 , 0 , 0 ) , 4 ) // 224.0.0.0/4 multicast
591+ || inRange ( octets ( 240 , 0 , 0 , 0 ) , 4 ) // 240.0.0.0/4 reserved
592+ || inRange ( octets ( 255 , 255 , 255 , 255 ) , 32 ) // 255.255.255.255/32 limited broadcast
593+ }
594+
595+ /// `bytes` is a 16-byte IPv6 address. Blocks loopback (`::1`), unspecified (`::`), link-local
596+ /// (`fe80::/10`), unique-local (`fc00::/7`), and IPv4-mapped addresses in a blocked v4 range.
597+ private static func isDisallowedIPv6( _ bytes: [ UInt8 ] ) -> Bool {
598+ guard bytes. count == 16 else { return true }
599+ // Loopback ::1
600+ if bytes [ 0 ..< 15 ] . allSatisfy ( { $0 == 0 } ) && bytes [ 15 ] == 1 { return true }
601+ // Unspecified ::
602+ if bytes. allSatisfy ( { $0 == 0 } ) { return true }
603+ // Link-local fe80::/10
604+ if bytes [ 0 ] == 0xfe && ( bytes [ 1 ] & 0xc0 ) == 0x80 { return true }
605+ // Unique-local fc00::/7
606+ if ( bytes [ 0 ] & 0xfe ) == 0xfc { return true }
607+ // IPv4-mapped ::ffff:a.b.c.d — re-check the embedded v4 address.
608+ if bytes [ 0 ..< 10 ] . allSatisfy ( { $0 == 0 } ) && bytes [ 10 ] == 0xff && bytes [ 11 ] == 0xff {
609+ let ipv4 = ( UInt32 ( bytes [ 12 ] ) << 24 ) | ( UInt32 ( bytes [ 13 ] ) << 16 ) | ( UInt32 ( bytes [ 14 ] ) << 8 ) | UInt32 ( bytes [ 15 ] )
610+ return isDisallowedIPv4 ( ipv4)
611+ }
612+ // NAT64 well-known prefix 64:ff9b::/96 — the last 4 bytes embed the real IPv4 target, so on a
613+ // NAT64 network `64:ff9b::a9fe:a9fe` would otherwise reach 169.254.169.254. Re-check the embed.
614+ if bytes [ 0 ] == 0x00 && bytes [ 1 ] == 0x64 && bytes [ 2 ] == 0xff && bytes [ 3 ] == 0x9b
615+ && bytes [ 4 ..< 12 ] . allSatisfy ( { $0 == 0 } ) {
616+ let ipv4 = ( UInt32 ( bytes [ 12 ] ) << 24 ) | ( UInt32 ( bytes [ 13 ] ) << 16 ) | ( UInt32 ( bytes [ 14 ] ) << 8 ) | UInt32 ( bytes [ 15 ] )
617+ return isDisallowedIPv4 ( ipv4)
618+ }
619+ return false
620+ }
621+ }
622+
623+ // MARK: - SSRF-guarded URLSession delegate
624+
625+ /// `URLSession` delegate that hardens `MapDataManager.importFromRemote` against the two SSRF tricks
626+ /// the up-front hostname check can't catch on its own:
627+ ///
628+ /// - **HTTP redirects** — `willPerformHTTPRedirection` re-runs `MapDataManager.isDisallowedHost` on
629+ /// every hop and refuses to follow a redirect to an internal host, so a public allow-listed URL
630+ /// can't `302` the request onto `169.254.169.254`, `localhost`, or a LAN address. This is the
631+ /// primary, reliable control.
632+ /// - **DNS rebinding** — `URLSession` re-resolves DNS itself at connect time, so a short-TTL record
633+ /// can pass validation with a public IP yet connect to an internal one. `didFinishCollecting`
634+ /// reads the real peer address from the transaction metrics and flags it; the caller then discards
635+ /// the response. This is best-effort defense-in-depth (metrics-based, so it prevents the fetched
636+ /// content from being imported rather than blocking the initial GET, which `URLSession` gives no
637+ /// pre-connect hook to stop).
638+ private final class SSRFGuardDelegate : NSObject , URLSessionTaskDelegate , @unchecked Sendable {
639+ private let lock = NSLock ( )
640+ private var disallowedPeer = false
641+
642+ /// `true` once any transaction in the task connected to an internal / non-routable peer.
643+ var connectedToDisallowedPeer : Bool {
644+ lock. lock ( ) ; defer { lock. unlock ( ) }
645+ return disallowedPeer
646+ }
647+
648+ private func flagDisallowedPeer( ) {
649+ lock. lock ( ) ; defer { lock. unlock ( ) }
650+ disallowedPeer = true
651+ }
652+
653+ func urlSession(
654+ _ session: URLSession ,
655+ task: URLSessionTask ,
656+ willPerformHTTPRedirection response: HTTPURLResponse ,
657+ newRequest request: URLRequest ,
658+ completionHandler: @escaping ( URLRequest ? ) -> Void
659+ ) {
660+ guard let host = request. url? . host, !host. isEmpty, !MapDataManager. isDisallowedHost ( host) else {
661+ // Disallowed or unparseable redirect target — do not follow it. Passing `nil` returns the
662+ // redirect response itself, which the caller treats as a non-2xx failure.
663+ completionHandler ( nil )
664+ return
665+ }
666+ completionHandler ( request)
667+ }
668+
669+ func urlSession(
670+ _ session: URLSession ,
671+ task: URLSessionTask ,
672+ didFinishCollecting metrics: URLSessionTaskMetrics
673+ ) {
674+ for transaction in metrics. transactionMetrics {
675+ guard let peer = transaction. remoteAddress, !peer. isEmpty else { continue }
676+ // `remoteAddress` is a literal IP; `isDisallowedHost` resolves literals locally (no network).
677+ if MapDataManager . isDisallowedHost ( peer) {
678+ flagDisallowedPeer ( )
679+ return
680+ }
681+ }
682+ }
683+ }
684+
459685// MARK: - Supporting Types
460686
461687/// Metadata for uploaded map data files
@@ -502,6 +728,7 @@ enum MapDataError: Error, LocalizedError {
502728 case invalidDestination
503729 case fileNotFound
504730 case saveFailed
731+ case disallowedHost
505732
506733 var errorDescription : String ? {
507734 switch self {
@@ -521,6 +748,8 @@ enum MapDataError: Error, LocalizedError {
521748 return " File not found. "
522749 case . saveFailed:
523750 return " Failed to save file. "
751+ case . disallowedHost:
752+ return String ( localized: " This URL is not allowed. " )
524753 }
525754 }
526755}
0 commit comments