diff --git a/README.md b/README.md index 1856e8d7..7df6e118 100644 --- a/README.md +++ b/README.md @@ -514,21 +514,33 @@ Supported image sources: | ------------- | ---------------------- | ----------------------------------------- | | Bundled asset | `require('./pin.png')` | Resolved on JS side before crossing Nitro | | Local URI | `{ uri: 'file:///…' }` | Platform file paths | -| Remote URL | `{ uri: 'https://…' }` | Async fetch with in-memory cache; Android restricts the host, see below | +| Remote URL | `{ uri: 'https://…' }` | Async fetch with in-memory cache; the host is restricted, see below | -### Remote image hosts on Android +### Remote image hosts -A remote URL you pass yourself — `image={{ uri: someUrl }}` — is checked before Android fetches -it. The URL must use `http`/`https`, must not carry `user:password@`, and its host must not -resolve to a private address: loopback, link-local, site-local, multicast, IPv6 unique-local, a -`.local`/`.localhost` name, or a cloud metadata endpoint. The address the connection is actually -made to is checked again before the request is sent, every redirect target is checked the same -way, and a redirect from `https` to `http` is not followed. This guards the common case of a -marker icon URL arriving as data from an API. A rejected URL falls back to the default pin and -logs `Rejected remote marker image URI` under the `NitroMaps` tag. +A remote URL you pass yourself — `image={{ uri: someUrl }}` — is checked on both platforms before +the image is fetched. The URL must use `http`/`https`, must not carry `user:password@`, and its +host must not resolve to a private address: loopback, any-local, link-local, site-local, +multicast, IPv6 unique-local, a `.local`/`.localhost` name, or a cloud metadata endpoint. Every +redirect target is checked the same way, so a permitted host cannot forward the fetch to a blocked +one, and a redirect from `https` to `http` is not followed. This guards the common case of a +marker icon URL arriving as data from an API. + +Android also checks the address the connection is actually made to, before it sends the request. +iOS checks the name only: `URLSession` resolves it again to connect, so a DNS answer that changes +in between is not re-checked there. Reaching the network the device itself is on still needs the +user's Local Network permission on iOS. + +A rejected URL logs `Rejected remote marker image URI` — under the `NitroMaps` logcat tag on +Android, and under the `NitroMaps` category of the `com.nitromaps` subsystem in the unified log +on iOS. The marker itself falls back to the default pin on both Google providers. On Apple Maps +it draws with no image at all, the same as any marker image that fails to load. `require()`d assets are exempt, including the packager URL Metro resolves them to in a -development build. iOS does not apply this policy today. +development build. + +If you need markers served from a private host, resolve it to an image yourself and pass the +bytes as a local file, or proxy it through a public endpoint. Additional props: diff --git a/package/ios/MarkerImageLoader.swift b/package/ios/MarkerImageLoader.swift index 5d9bd8f8..3eb5974e 100644 --- a/package/ios/MarkerImageLoader.swift +++ b/package/ios/MarkerImageLoader.swift @@ -1,3 +1,4 @@ +import OSLog import UIKit /// Loads marker images from bundled assets or remote URLs with in-memory caching. @@ -11,11 +12,30 @@ enum MarkerImageLoader { cache.totalCostLimit = maximumCacheBytes return cache }() - private static let session = URLSession.shared + /// Not `URLSession.shared`, which cannot carry a delegate: without one the policy would check + /// the URL the app supplied and then follow a redirect anywhere, which is the cheapest way to + /// defeat it — an attacker-controlled public host answering 302 to a private address. + private static let session: URLSession = { + URLSession( + configuration: .default, + delegate: RedirectPolicyDelegate(), + delegateQueue: nil + ) + }() private static let decodeQueue = DispatchQueue( label: "com.nitromaps.markerImageDecode", qos: .userInitiated ) + /// Resolving a host blocks, and a slow lookup must not hold up local decoding. Serial rather + /// than concurrent so a screen full of markers cannot spawn a thread per lookup; the image + /// cache means each distinct URI is resolved once. + private static let policyQueue = DispatchQueue( + label: "com.nitromaps.markerImagePolicy", + qos: .userInitiated + ) + /// The category matches `NITRO_MAPS_LOG_TAG` on Android, so a rejected image is found the same + /// way on both platforms. + private static let logger = Logger(subsystem: "com.nitromaps", category: "NitroMaps") static func cachedImage(for image: MarkerImage) -> UIImage? { cache.object(forKey: cacheKey(for: image)) @@ -32,7 +52,7 @@ enum MarkerImageLoader { } let uri = image.uri - if uri.hasPrefix("http://") || uri.hasPrefix("https://") { + if RemoteMarkerUriPolicy.isRemoteUri(uri) { loadRemote(uri: uri, cacheKey: cacheKey, image: image, completion: completion) return } @@ -52,7 +72,10 @@ enum MarkerImageLoader { let width = image.width.map { String($0) } ?? "" let height = image.height.map { String($0) } ?? "" let scale = image.scale.map { String($0) } ?? "" - return "\(image.uri)|\(width)|\(height)|\(scale)" as NSString + // `origin` is part of the key so a bundled image cannot warm the cache for a user-supplied + // one with the same URI, which would hand it a policy-free entry. + let origin = image.origin.map { $0.stringValue } ?? "" + return "\(image.uri)|\(width)|\(height)|\(scale)|\(origin)" as NSString } private static func loadLocal(uri: String, image: MarkerImage) -> UIImage? { @@ -85,12 +108,76 @@ enum MarkerImageLoader { image: MarkerImage, completion: @escaping (UIImage?) -> Void ) { - guard let url = URL(string: uri) else { + // No policy applies to an image the app bundled, so it must not wait behind the host lookup + // of an unrelated one: in a development build every `require()`d marker takes this path. + if image.origin == .bundled { + guard let url = URL(string: uri) else { + completion(nil) + return + } + startRemoteTask(url: url, cacheKey: cacheKey, image: image, completion: completion) + return + } + + // The pre-check classifies a numeric host literal without touching DNS, so the common + // rejection costs nothing and never leaves the caller's thread. + if let reason = RemoteMarkerUriPolicy.rejectReason( + uri: uri, + isBundled: false, + resolveHostAddress: false + ) { + logRejected(uri: uri, reason: reason) completion(nil) return } - session.dataTask(with: url) { data, _, _ in + policyQueue.async { + if let reason = RemoteMarkerUriPolicy.rejectReason( + uri: uri, + isBundled: false, + resolveHostAddress: true + ) { + logRejected(uri: uri, reason: reason) + DispatchQueue.main.async { completion(nil) } + return + } + + guard let url = URL(string: uri) else { + DispatchQueue.main.async { completion(nil) } + return + } + + // URLSession resolves the host again to connect, and it has no hook between that lookup and + // the request. So unlike Android, which checks the address it connected to, an answer that + // changes after the check above (DNS rebinding) is not re-checked. Reaching the network the + // device itself is on still needs the user's Local Network permission. + startRemoteTask(url: url, cacheKey: cacheKey, image: image, completion: completion) + } + } + + /// Logged twice on purpose. A rejected URI can carry the very thing that got it rejected — + /// `user:password@` is one of the reasons — and a query string can hold a signed token, so the + /// public half is stripped of both. The full URI follows as private: the unified log keeps it + /// out of a shipped app's logs, and a developer attached to the simulator or a debugger still + /// sees it. That matters because Metro puts the asset's identity in the query string, so the + /// stripped form alone does not say which image was refused. + private static func logRejected(uri: String, reason: String) { + logger.warning( + """ + Rejected remote marker image URI (\(reason, privacy: .public)): \ + \(RemoteMarkerUriPolicy.loggableURI(uri), privacy: .public) \ + [full: \(uri, privacy: .private)] + """ + ) + } + + private static func startRemoteTask( + url: URL, + cacheKey: NSString, + image: MarkerImage, + completion: @escaping (UIImage?) -> Void + ) { + let task = session.dataTask(with: url) { data, _, _ in let uiImage: UIImage? if let data, let decoded = UIImage(data: data) { uiImage = resize(decoded, image: image) @@ -104,7 +191,59 @@ enum MarkerImageLoader { DispatchQueue.main.async { completion(uiImage) } - }.resume() + } + // The delegate sees tasks, not images, so the exemption has to travel with the task. + task.taskDescription = image.origin == .bundled ? bundledTaskDescription : nil + task.resume() + } + + private static let bundledTaskDescription = "com.nitromaps.bundledMarkerImage" + + /// Re-runs the host policy on every redirect destination. Without it the policy would only ever + /// see the URL the app supplied. + private final class RedirectPolicyDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + guard let destination = request.url else { + completionHandler(nil) + return + } + + if let source = response.url, + let reason = RemoteMarkerUriPolicy.redirectRejectReason(from: source, to: destination) + { + MarkerImageLoader.logRejected(uri: destination.absoluteString, reason: reason) + completionHandler(nil) + return + } + + if task.taskDescription == MarkerImageLoader.bundledTaskDescription { + completionHandler(request) + return + } + + let uri = destination.absoluteString + + // Off the delegate queue: resolving blocks, and this queue also delivers completions. + MarkerImageLoader.policyQueue.async { + if let reason = RemoteMarkerUriPolicy.rejectReason( + uri: uri, + isBundled: false, + resolveHostAddress: true + ) { + MarkerImageLoader.logRejected(uri: uri, reason: "redirect: \(reason)") + // `nil` stops the redirect; the task completes with the redirect response body. + completionHandler(nil) + return + } + completionHandler(request) + } + } } /// Decoded size in bytes, so the cache evicts by memory rather than by count. diff --git a/package/ios/Package.swift b/package/ios/Package.swift index b2d9efa5..7545bbcc 100644 --- a/package/ios/Package.swift +++ b/package/ios/Package.swift @@ -28,6 +28,10 @@ let package = Package( name: "NitroMapsShapeDiff", path: "ShapeDiff" ), + .target( + name: "NitroMapsRemoteImagePolicy", + path: "RemoteImagePolicy" + ), .testTarget( name: "NitroMapsColorParserTests", dependencies: ["NitroMapsColorParser"], @@ -54,5 +58,10 @@ let package = Package( dependencies: ["NitroMapsShapeDiff"], path: "Tests/ShapeDiff" ), + .testTarget( + name: "NitroMapsRemoteImagePolicyTests", + dependencies: ["NitroMapsRemoteImagePolicy"], + path: "Tests/RemoteImagePolicy" + ), ] ) diff --git a/package/ios/RemoteImagePolicy/RemoteMarkerUriPolicy.swift b/package/ios/RemoteImagePolicy/RemoteMarkerUriPolicy.swift new file mode 100644 index 00000000..815daf0e --- /dev/null +++ b/package/ios/RemoteImagePolicy/RemoteMarkerUriPolicy.swift @@ -0,0 +1,272 @@ +import Foundation + +/// Decides whether a marker image may be fetched over the network. +/// +/// The policy exists for URLs that arrive as *data* — `image={{ uri: apiResponse.iconUrl }}` +/// pointing at a router admin page or a cloud metadata endpoint — so it only applies to images the +/// app did not bundle. A `require()`d asset carries `MarkerImageOrigin.bundled` from the JS side and +/// skips the check: a development build resolves it to a Metro packager URL, whose address is always +/// private (`localhost` through the simulator, `192.168.x.x` on a device), so the host cannot tell a +/// bundled asset apart from the addresses this policy blocks. +/// +/// Mirrors `RemoteMarkerUriPolicy.kt`. The two are meant to answer the same way for the same URL; +/// the divergences that Foundation forces are called out where they happen. +enum RemoteMarkerUriPolicy { + /// Whether `uriString` is fetched over the network rather than read from the app bundle. + static func isRemoteUri(_ uriString: String) -> Bool { + guard let scheme = URLComponents(string: uriString)?.scheme?.lowercased() else { + return false + } + return scheme == "http" || scheme == "https" + } + + /// Returns why the image must not be fetched, or `nil` when the fetch is allowed. + /// + /// - Parameters: + /// - isBundled: whether the image was stamped `MarkerImageOrigin.bundled` on the JS side. + /// - resolveHostAddress: whether a host name may be resolved through DNS. `false` for the + /// pre-check on the calling thread, `true` once the caller has hopped to a background queue, + /// so only a numeric host literal is classified before the thread hop. + static func rejectReason( + uri uriString: String, + isBundled: Bool, + resolveHostAddress: Bool + ) -> String? { + if isBundled { + return nil + } + + guard let components = URLComponents(string: uriString) else { + return "invalid URI" + } + + switch components.scheme?.lowercased() { + case "http", "https": break + case nil: return "missing scheme" + default: return "unsupported scheme" + } + + // Two accessors, because `user` is `nil` for `http://:secret@host` while `password` is not. + if components.user != nil || components.password != nil { + return "user info not allowed" + } + + // `host` is the decoded form, so `http://%31%30.0.2.2/` is classified as the 10.0.2.2 it + // resolves to rather than as an opaque name. `encodedHost` is the ASCII wire form, which is + // what DNS takes for an internationalised name. + guard let host = components.host?.lowercased(), !host.isEmpty else { + return "missing host" + } + + let allowed = isAllowlistedHost( + host, + encodedHost: components.encodedHost?.lowercased(), + resolveHostAddress: resolveHostAddress + ) + return allowed ? nil : "host not allowlisted" + } + + /// Returns why a redirect from `source` to `destination` must not be followed, or `nil` when it + /// may be. + /// + /// This holds for a bundled image too: a redirect stays on `http`/`https`, and never goes from + /// `https` down to `http`. The destination of a user-supplied image still has to pass + /// `rejectReason(uri:isBundled:resolveHostAddress:)`, like any other URI. + static func redirectRejectReason(from source: URL, to destination: URL) -> String? { + let scheme = destination.scheme?.lowercased() + if scheme != "http" && scheme != "https" { + return "redirect to an unsupported scheme" + } + if scheme == "http" && source.scheme?.lowercased() == "https" { + return "redirect from https to http" + } + return nil + } + + /// The parts of a rejected URI that are safe to write to the log. + /// + /// A rejected URI can carry the very thing that got it rejected — `user:password@` is one of the + /// reasons — and a query string can hold a signed token. What is left identifies which image + /// failed, which is what a developer reading the log needs. + static func loggableURI(_ uriString: String) -> String { + guard var components = URLComponents(string: uriString) else { + return "" + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.string ?? "" + } + + /// The resolving half of the host check, without the URL parsing around it. + /// + /// Blocking: only call it off the thread the caller came in on. + static func isAllowlistedResolvedHost(_ host: String) -> Bool { + let addresses = resolveAddresses(of: bracketless(host)) + if addresses.isEmpty { + return false + } + // Stricter than the Kotlin original, which classifies `InetAddress.getByName`'s first answer + // only: a name that resolves to both a public and a private address is rejected. + return addresses.allSatisfy(isAllowlistedAddress) + } + + private static func isAllowlistedHost( + _ host: String, + encodedHost: String?, + resolveHostAddress: Bool + ) -> Bool { + if host == "localhost" || host.hasSuffix(".localhost") || host.hasSuffix(".local") { + return false + } + if blockedHosts.contains(host) { + return false + } + + if let literal = parseLiteral(bracketless(host)) { + return isAllowlistedAddress(literal) + } + + // A name can only be classified by resolving it, and that must not happen on the calling + // thread. Allow it here; the pass that may resolve makes the real decision. + if !resolveHostAddress { + return true + } + + return isAllowlistedResolvedHost(encodedHost ?? host) + } + + /// An address reduced to the bytes the classification needs. + private enum IPAddress { + /// Host byte order. + case v4(UInt32) + /// 16 bytes, network order. + case v6([UInt8]) + } + + private static func parseLiteral(_ text: String) -> IPAddress? { + var v4 = in_addr() + if inet_pton(AF_INET, text, &v4) == 1 { + return .v4(UInt32(bigEndian: v4.s_addr)) + } + + var v6 = in6_addr() + if inet_pton(AF_INET6, text, &v6) == 1 { + return ipAddress(from: v6) + } + + return nil + } + + private static func ipAddress(from address: in6_addr) -> IPAddress { + var bytes = [UInt8](repeating: 0, count: 16) + withUnsafeBytes(of: address) { raw in + for index in 0..<16 { + bytes[index] = raw[index] + } + } + + if let embedded = embeddedIPv4(in: bytes) { + return .v4(embedded) + } + return .v6(bytes) + } + + /// The IPv4 address inside `::ffff:a.b.c.d` or `::a.b.c.d`, which every stack routes to the v4 + /// address it wraps. `InetAddress.getByName` unwraps the mapped form on its own, so without this + /// `::ffff:127.0.0.1` would pass every IPv6 rule. + private static func embeddedIPv4(in bytes: [UInt8]) -> UInt32? { + guard bytes[0..<10].allSatisfy({ $0 == 0 }) else { + return nil + } + + let isMapped = bytes[10] == 0xff && bytes[11] == 0xff + let isCompatible = bytes[10] == 0 && bytes[11] == 0 + guard isMapped || isCompatible else { + return nil + } + + let value = + UInt32(bytes[12]) << 24 | UInt32(bytes[13]) << 16 | UInt32(bytes[14]) << 8 | UInt32(bytes[15]) + // `::` and `::1` are the unspecified and loopback addresses, not an embedded IPv4. + if isCompatible && value <= 1 { + return nil + } + return value + } + + private static func isAllowlistedAddress(_ address: IPAddress) -> Bool { + switch address { + case .v4(let value): + let first = UInt8(truncatingIfNeeded: value >> 24) + let second = UInt8(truncatingIfNeeded: value >> 16) + + if value == 0 { return false } // any-local + if first == 127 { return false } // loopback + if first == 169 && second == 254 { return false } // link-local + if first == 10 { return false } // site-local + if first == 172 && (second & 0xf0) == 16 { return false } // site-local + if first == 192 && second == 168 { return false } // site-local + if (first & 0xf0) == 0xe0 { return false } // multicast + return true + + case .v6(let bytes): + if bytes.allSatisfy({ $0 == 0 }) { return false } // any-local + if bytes[0..<15].allSatisfy({ $0 == 0 }) && bytes[15] == 1 { return false } // loopback + if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80 { return false } // link-local + if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0xc0 { return false } // site-local + if bytes[0] == 0xff { return false } // multicast + if (bytes[0] & 0xfe) == 0xfc { return false } // unique-local + return true + } + } + + /// Blocking, so it may only run off the thread the caller came in on. + private static func resolveAddresses(of host: String) -> [IPAddress] { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + + var result: UnsafeMutablePointer? + guard getaddrinfo(host, nil, &hints, &result) == 0, let head = result else { + return [] + } + defer { freeaddrinfo(head) } + + var addresses: [IPAddress] = [] + var entry: UnsafeMutablePointer? = head + while let current = entry { + if let socketAddress = current.pointee.ai_addr { + switch Int32(socketAddress.pointee.sa_family) { + case AF_INET: + socketAddress.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { + addresses.append(.v4(UInt32(bigEndian: $0.pointee.sin_addr.s_addr))) + } + case AF_INET6: + socketAddress.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { + addresses.append(ipAddress(from: $0.pointee.sin6_addr)) + } + default: + break + } + } + entry = current.pointee.ai_next + } + return addresses + } + + /// `URLComponents.host` keeps the brackets around an IPv6 literal, the way `URI.getHost` does; + /// `inet_pton` and `getaddrinfo` reject them. + private static func bracketless(_ host: String) -> String { + guard host.count > 2, host.hasPrefix("["), host.hasSuffix("]") else { + return host + } + return String(host.dropFirst().dropLast()) + } + + private static let blockedHosts: Set = [ + "metadata.google.internal", + "metadata.goog", + ] +} diff --git a/package/ios/Tests/RemoteImagePolicy/RemoteMarkerUriPolicyTests.swift b/package/ios/Tests/RemoteImagePolicy/RemoteMarkerUriPolicyTests.swift new file mode 100644 index 00000000..3713927e --- /dev/null +++ b/package/ios/Tests/RemoteImagePolicy/RemoteMarkerUriPolicyTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing + +@testable import NitroMapsRemoteImagePolicy + +/// Runs the pre-check, which classifies a numeric host literal without a DNS lookup — so every +/// assertion in this file stays off the network. +private func rejectReason(_ uri: String, isBundled: Bool = false) -> String? { + RemoteMarkerUriPolicy.rejectReason(uri: uri, isBundled: isBundled, resolveHostAddress: false) +} + +@Test +func treatsOnlyHttpUrisAsRemote() { + #expect(RemoteMarkerUriPolicy.isRemoteUri("http://example.com/pin.png")) + #expect(RemoteMarkerUriPolicy.isRemoteUri("HTTPS://example.com/pin.png")) + #expect(!RemoteMarkerUriPolicy.isRemoteUri("file:///data/pin.png")) + #expect(!RemoteMarkerUriPolicy.isRemoteUri("pin")) + #expect(!RemoteMarkerUriPolicy.isRemoteUri("asset:/pin.png")) +} + +@Test +func rejectsPrivateAddressesTheDeveloperMachineCouldBeReachedAt() { + // Every development setup lands on one of these: the simulator sharing the host's loopback, + // a device on the same Wi-Fi, and the Android emulator's host alias. + #expect(rejectReason("http://127.0.0.1:8081/assets/pin.png") == "host not allowlisted") + #expect(rejectReason("http://192.168.1.14:8081/assets/pin.png") == "host not allowlisted") + #expect(rejectReason("http://10.0.2.2:8095/assets/pin.png") == "host not allowlisted") + #expect(rejectReason("http://172.16.0.1/pin.png") == "host not allowlisted") + #expect(rejectReason("http://0.0.0.0/pin.png") == "host not allowlisted") + #expect(rejectReason("http://239.1.2.3/pin.png") == "host not allowlisted") +} + +@Test +func rejectsLinkLocalAndMetadataEndpoints() { + #expect(rejectReason("http://169.254.169.254/latest/meta-data/") == "host not allowlisted") + #expect( + rejectReason("http://metadata.google.internal/computeMetadata/v1/") == "host not allowlisted") + #expect(rejectReason("http://localhost/pin.png") == "host not allowlisted") + #expect(rejectReason("http://printer.local/pin.png") == "host not allowlisted") + #expect(rejectReason("http://metro.localhost/pin.png") == "host not allowlisted") +} + +@Test +func rejectsPrivateIPv6Literals() { + #expect(rejectReason("http://[::1]:8081/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[::]/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[fe80::1]/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[fec0::1]/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[fd12:3456::1]/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[ff02::1]/pin.png") == "host not allowlisted") +} + +@Test +func rejectsAPrivateIPv4AddressWearingAnIPv6Shape() { + // `::ffff:127.0.0.1` matches no IPv6 rule, and every stack routes it to 127.0.0.1. + #expect(rejectReason("http://[::ffff:127.0.0.1]/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[::ffff:10.0.2.2]/pin.png") == "host not allowlisted") + #expect(rejectReason("http://[::192.168.1.14]/pin.png") == "host not allowlisted") +} + +@Test +func rejectsAPrivateHostHiddenByPercentEncoding() { + // `URLComponents.host` decodes, so the address the request would reach is the one classified. + #expect(rejectReason("http://%31%30.0.2.2/pin.png") == "host not allowlisted") + #expect(rejectReason("http://%6c%6f%63%61%6c%68%6f%73%74/pin.png") == "host not allowlisted") +} + +@Test +func rejectsMalformedAndCredentialBearingUris() { + #expect(rejectReason("ftp://example.com/pin.png") == "unsupported scheme") + #expect(rejectReason("//example.com/pin.png") == "missing scheme") + #expect(rejectReason("http:///pin.png") == "missing host") + #expect(rejectReason("http://exa mple.com/pin.png") == "invalid URI") + #expect(rejectReason("https://user:secret@example.com/pin.png") == "user info not allowed") + #expect(rejectReason("https://:secret@example.com/pin.png") == "user info not allowed") +} + +@Test +func allowsAPublicHost() { + #expect(rejectReason("https://cdn.example.com/pin.png") == nil) + #expect(rejectReason("https://93.184.216.34/pin.png") == nil) + #expect(rejectReason("https://[2606:2800:220:1:248:1893:25c8:1946]/pin.png") == nil) +} + +@Test +func skipsTheHostPolicyForABundledImage() { + // The packager URL a development build resolves `require('./pin.png')` to; the origin stamp is + // the only thing keeping it out of the rejections above. + #expect( + rejectReason("http://localhost:8081/assets/?unstable_path=./assets/pin.png", isBundled: true) + == nil) +} + +@Test +func appliesTheHostPolicyToAnImageTheAppDidNotBundle() { + #expect( + rejectReason("http://localhost:8081/assets/?unstable_path=./assets/pin.png") + == "host not allowlisted") +} + +@Test +func classifiesANumericLiteralWithoutDeferringToDns() { + // The resolving pass answers a literal the same way the pre-check does, so a reachable + // private address never reaches the network even once the caller is off the main thread. + #expect( + RemoteMarkerUriPolicy.rejectReason( + uri: "http://127.0.0.1:8081/pin.png", isBundled: false, resolveHostAddress: true) + == "host not allowlisted") + #expect( + RemoteMarkerUriPolicy.rejectReason( + uri: "http://[fd00::1]/pin.png", isBundled: false, resolveHostAddress: true) + == "host not allowlisted") +} + +@Test +func classifiesWhatTheResolverAnswers() { + // A numeric literal is answered by `getaddrinfo` without a DNS query, so this exercises the + // real resolver path — sockaddr parsing and byte order included — while staying off the + // network. Reading `sin_addr` in the wrong order would let 10.0.2.2 through as 2.2.0.10. + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("127.0.0.1")) + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("10.0.2.2")) + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("192.168.1.14")) + #expect(RemoteMarkerUriPolicy.isAllowlistedResolvedHost("93.184.216.34")) + + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("::1")) + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("[::1]")) + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("fd00::1")) + #expect(!RemoteMarkerUriPolicy.isAllowlistedResolvedHost("::ffff:10.0.2.2")) + #expect(RemoteMarkerUriPolicy.isAllowlistedResolvedHost("2606:2800:220:1:248:1893:25c8:1946")) +} + +@Test +func classifiesARedirectDestinationTheSameWayAsAnOriginalUri() { + // A redirect destination goes through the same call the delegate makes, so an attacker-controlled + // public host answering 302 to a private address gains nothing over naming it directly. + #expect(rejectReason("http://169.254.169.254/latest/meta-data/") == "host not allowlisted") + #expect(rejectReason("http://127.0.0.1/pin.png") == "host not allowlisted") + #expect(rejectReason("ftp://example.com/pin.png") == "unsupported scheme") + #expect(rejectReason("https://cdn.example.com/pin.png") == nil) +} + +@Test +func followsARedirectOnlyOnHttpAndNeverDownToIt() { + func reason(from source: String, to destination: String) -> String? { + RemoteMarkerUriPolicy.redirectRejectReason( + from: URL(string: source)!, + to: URL(string: destination)! + ) + } + + #expect(reason(from: "http://cdn.example.com/pin.png", to: "https://cdn.example.com/pin.png") == nil) + #expect(reason(from: "https://cdn.example.com/pin.png", to: "https://img.example.com/pin.png") == nil) + #expect( + reason(from: "https://cdn.example.com/pin.png", to: "http://cdn.example.com/pin.png") + == "redirect from https to http") + #expect( + reason(from: "http://cdn.example.com/pin.png", to: "file:///var/mobile/pin.png") + == "redirect to an unsupported scheme") +} + +@Test +func stripsCredentialsAndQueryFromALoggedUri() { + #expect( + RemoteMarkerUriPolicy.loggableURI("https://user:secret@example.com/pin.png") + == "https://example.com/pin.png") + #expect( + RemoteMarkerUriPolicy.loggableURI("https://example.com/pin.png?token=abc123&x=1") + == "https://example.com/pin.png") + #expect( + RemoteMarkerUriPolicy.loggableURI("https://example.com/pin.png#frag") + == "https://example.com/pin.png") + // The part a developer needs to recognise the image survives untouched. + #expect( + RemoteMarkerUriPolicy.loggableURI("http://10.0.2.2:8095/assets/pin.png") + == "http://10.0.2.2:8095/assets/pin.png") + #expect(RemoteMarkerUriPolicy.loggableURI("http://exa mple.com/x") == "") +} diff --git a/package/iosTests/GoogleMarkerVisualApplierTests.swift b/package/iosTests/GoogleMarkerVisualApplierTests.swift index 72383830..3f5f1c14 100644 --- a/package/iosTests/GoogleMarkerVisualApplierTests.swift +++ b/package/iosTests/GoogleMarkerVisualApplierTests.swift @@ -17,7 +17,8 @@ final class GoogleMarkerVisualApplierTests: XCTestCase { uri: "https://example.test/pin.png", width: 40, height: 40, - scale: 1 + scale: 1, + origin: nil ) applier.apply(markerDescriptor(image: image, anchor: MarkerAnchor(x: 0, y: 0)), to: marker) @@ -46,13 +47,15 @@ final class GoogleMarkerVisualApplierTests: XCTestCase { uri: "https://example.test/a.png", width: 40, height: 40, - scale: 1 + scale: 1, + origin: nil ) let imageB = MarkerImage( uri: "https://example.test/b.png", width: 40, height: 40, - scale: 1 + scale: 1, + origin: nil ) let applier = GoogleMarkerVisualApplier( cachedImage: { image in image.uri.hasSuffix("a.png") ? iconA : nil },