Skip to content

Commit 55af7ae

Browse files
committed
Merge remote-tracking branch 'origin/main' into security/ssrf-pki-hardening
# Conflicts: # Meshtastic/Helpers/MapDataManager.swift # MeshtasticTests/MapDataManagerTests.swift
2 parents a561ea6 + 9027e96 commit 55af7ae

10 files changed

Lines changed: 134 additions & 525 deletions

Meshtastic/Helpers/LocalNotificationManager.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ import OSLog
99
class LocalNotificationManager {
1010

1111
var notifications = [Notification]()
12+
private let removeDeliveredNotifications: ([String]) -> Void
1213
let thumbsUpAction = UNNotificationAction(identifier: "messageNotification.thumbsUpAction", title: "👍 \(Tapbacks.thumbsUp.description)", options: [])
1314
let thumbsDownAction = UNNotificationAction(identifier: "messageNotification.thumbsDownAction", title: "👎 \(Tapbacks.thumbsDown.description)", options: [])
1415
let replyInputAction = UNTextInputNotificationAction(identifier: "messageNotification.replyInputAction", title: "Reply".localized, options: [])
1516

17+
init(removeDeliveredNotifications: @escaping ([String]) -> Void = { identifiers in
18+
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
19+
}) {
20+
self.removeDeliveredNotifications = removeDeliveredNotifications
21+
}
22+
1623
// Step 1 Request Permissions for notifications
1724
private func requestAuthorization() async {
1825
do {
@@ -124,6 +131,12 @@ class LocalNotificationManager {
124131
func cancelNotificationsForMessageIds(_ messageIds: [Int64]) {
125132
let messageIDSet = Set(messageIds)
126133
guard !messageIDSet.isEmpty else { return }
134+
let deliveredNotificationIdentifiers = messageIds.map { "notification.id.\($0)" }
135+
136+
// Pending requests have not surfaced to the user yet; delivered notifications may
137+
// still be visible on the Lock Screen or in Notification Center. Remove both when
138+
// their messages have been read in the app.
139+
removeDeliveredNotifications(deliveredNotificationIdentifiers)
127140

128141
UNUserNotificationCenter.current().getPendingNotificationRequests { notifications in
129142
let identifiers = notifications.compactMap { notification -> String? in

Meshtastic/Helpers/MapDataManager.swift

Lines changed: 0 additions & 277 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

Meshtastic/Router/Router.swift

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,6 @@ class Router: ObservableObject {
134134
routeNodes(components)
135135
} else if components.path == "/map" {
136136
routeMap(components)
137-
} else if components.path == "/importGeoJSON" {
138-
routeImportGeoJSON(components)
139137
} else if components.path.hasPrefix("/settings") {
140138
routeSettings(components)
141139
} else {
@@ -235,31 +233,10 @@ class Router: ObservableObject {
235233
}
236234
}
237235

238-
/// Downloads (http/https) or reads (file) a GeoJSON map overlay from the `url` query param and
239-
/// imports it via `MapDataManager`, reusing the same pipeline as the in-app file picker. Lets a
240-
/// coverage map be imported hands-free, e.g. `xcrun simctl openurl <udid> "meshtastic:///importGeoJSON?url=<encoded-url>"`.
241-
private func routeImportGeoJSON(_ components: URLComponents) {
242-
guard let urlString = components.queryItems?.first(where: { $0.name == "url" })?.value else {
243-
Logger.services.warning("🛣 [App] importGeoJSON route missing required 'url' query param.")
244-
return
245-
}
246-
247-
selectedTab = .map
248-
249-
Task {
250-
do {
251-
let metadata = try await MapDataManager.shared.importFromRemote(urlString: urlString)
252-
Logger.services.info("🗺️ [App] Imported '\(metadata.originalName, privacy: .public)' (\(metadata.overlayCount, privacy: .public) overlays) via importGeoJSON deep link.")
253-
} catch {
254-
Logger.services.error("🗺️ [App] importGeoJSON deep link failed: \(error.localizedDescription, privacy: .public)")
255-
}
256-
}
257-
}
258-
259236
/// Imports a GeoJSON map overlay handed to the app as a file URL — e.g. via "Open in Meshtastic"
260237
/// from the Share Sheet, the Files app, or a drag-and-drop — reusing the same pipeline as the
261-
/// in-app file picker and the `importGeoJSON` deep link. Called from `onOpenURL` for file URLs,
262-
/// which is a distinct path from `route(url:)` (that only handles `meshtastic://` scheme URLs).
238+
/// in-app file picker. Called from `onOpenURL` for file URLs, which is a distinct path from
239+
/// `route(url:)` (that only handles `meshtastic://` scheme URLs).
263240
func importMapFile(url: URL) {
264241
selectedTab = .map
265242

0 commit comments

Comments
 (0)