Skip to content

Commit 4c679be

Browse files
committed
Harden SSRF guard and public-key substitution defenses
SSRF (MapDataManager.importFromRemote — reachable from the untrusted importGeoJSON deep link): - Re-validate every HTTP redirect hop's host so a public allow-listed URL can't 302 the request onto localhost/LAN/169.254.169.254. - Discard the response if the connection lands on an internal peer (DNS-rebinding defense-in-depth via transaction metrics). - Add RFC 5737 test-nets and 255.255.255.255/32 to the IPv4 denylist. - Block the NAT64 well-known prefix 64:ff9b::/96 by re-checking the embedded IPv4. Public-key substitution / MITM (node ingestion): - Extract first-wins key handling into UserEntity.applyInboundPublicKey and route all three ingestion paths (UpdateSwiftData NodeInfo + User, MeshPackets NodeInfo) through it. - On a differing inbound key, keep the trusted stored key AND surface the attempt to the UI (keyMatch=false, newPublicKey=inbound) instead of only logging — drives the red key.slash indicator across the node views. - Fix the divergent `publicKey == nil` guard in MeshPackets that previously ignored mismatches silently. Tests: - 21-case host/IP denylist suite + file:// rejection (fixes a stale test that asserted the opposite contract). - 6-case suite for the key-substitution helper.
1 parent e2b6cec commit 4c679be

8 files changed

Lines changed: 423 additions & 25 deletions

File tree

Meshtastic/Helpers/MapDataManager.swift

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

Meshtastic/Helpers/MeshPackets.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -581,11 +581,9 @@ actor MeshPackets {
581581
modelContext.insert(newUserEntity)
582582
fetchedNode[0].user = newUserEntity
583583
}
584-
// Set the public key for a user if it is empty, don't update
585-
if fetchedNode[0].user?.publicKey == nil && !nodeInfo.user.publicKey.isEmpty {
586-
fetchedNode[0].user?.pkiEncrypted = true
587-
fetchedNode[0].user?.publicKey = nodeInfo.user.publicKey
588-
}
584+
// First-wins on the public key, consistent with the NodeInfo/User paths in UpdateSwiftData
585+
// (previously a `== nil` guard here silently ignored mismatches). See `applyInboundPublicKey`.
586+
fetchedNode[0].user?.applyInboundPublicKey(nodeInfo.user.publicKey, nodeNum: Int64(nodeInfo.num))
589587
fetchedNode[0].user?.userId = nodeInfo.num.toHex()
590588
fetchedNode[0].user?.num = Int64(nodeInfo.num)
591589
fetchedNode[0].user?.numString = String(nodeInfo.num)

Meshtastic/Model/UserEntity.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import Foundation
9+
import OSLog
910
import SwiftData
1011

1112
@Model
@@ -38,3 +39,50 @@ final class UserEntity {
3839

3940
init() {}
4041
}
42+
43+
// MARK: - First-wins public-key handling
44+
45+
extension UserEntity {
46+
47+
/// The result of applying an inbound public key to this user via `applyInboundPublicKey(_:nodeNum:)`.
48+
enum PublicKeyUpdateOutcome: Equatable {
49+
/// The inbound key was empty — nothing to apply.
50+
case ignoredEmpty
51+
/// No key was on file yet, so the inbound key was stored (and `pkiEncrypted` set).
52+
case stored
53+
/// The inbound key matched the stored key — no change.
54+
case matched
55+
/// A *different* key arrived for a contact that already has one. The stored key is kept
56+
/// (first-wins); `keyMatch` is cleared and `newPublicKey` records the rejected key so the UI
57+
/// trust indicators surface the possible key-substitution attempt.
58+
case mismatch
59+
}
60+
61+
/// Applies **first-wins** semantics for an inbound public key.
62+
///
63+
/// Once a non-empty key is stored for a contact it is never silently overwritten by a different
64+
/// inbound key — that would let any mesh/MQTT peer substitute a trusted contact's key and MITM the
65+
/// victim's PKC direct messages. A differing key is refused and surfaced to the UI (`keyMatch =
66+
/// false`, `newPublicKey = inbound`) rather than only logged; the stored key stands.
67+
///
68+
/// - Parameters:
69+
/// - inboundKey: The public key carried by the inbound NodeInfo/User protobuf.
70+
/// - nodeNum: The node number, used only for the security log line on a mismatch.
71+
/// - Returns: The outcome, primarily for tests and callers that want to react to a mismatch.
72+
@discardableResult
73+
func applyInboundPublicKey(_ inboundKey: Data, nodeNum: Int64) -> PublicKeyUpdateOutcome {
74+
guard !inboundKey.isEmpty else { return .ignoredEmpty }
75+
76+
if let storedKey = publicKey, !storedKey.isEmpty {
77+
guard storedKey != inboundKey else { return .matched }
78+
keyMatch = false
79+
newPublicKey = inboundKey
80+
Logger.data.error("🔐 [Security] Ignoring inbound public key change for node \(nodeNum, privacy: .public); a different key is already stored (possible key-substitution attempt).")
81+
return .mismatch
82+
}
83+
84+
pkiEncrypted = true
85+
publicKey = inboundKey
86+
return .stored
87+
}
88+
}

Meshtastic/Persistence/NodeBackupManager+Import.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ extension NodeBackupManager {
4040
let backupUsers = try backupContext.fetch(FetchDescriptor<UserEntity>())
4141
var usersByNum: [Int64: UserEntity] = [:]
4242
for src in backupUsers {
43+
// Each `dst` is a freshly-created entity that we insert into the live context — this copy never
44+
// overwrites the public key of an already-populated *live* record, so the inbound-mesh
45+
// first-wins key protection (UpdateSwiftData / MeshPackets) doesn't apply here. This is a
46+
// user-initiated restore of the user's own trusted backup, carrying `keyMatch`/`newPublicKey`
47+
// forward verbatim.
4348
let dst = UserEntity()
4449
dst.hwDisplayName = src.hwDisplayName
4550
dst.hwModel = src.hwModel

Meshtastic/Persistence/UpdateSwiftData.swift

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -511,10 +511,8 @@ extension MeshPackets {
511511
fetchedNode[0].user?.unmessagable = false
512512
}
513513
}
514-
if !nodeInfoMessage.user.publicKey.isEmpty {
515-
fetchedNode[0].user?.pkiEncrypted = true
516-
fetchedNode[0].user?.publicKey = nodeInfoMessage.user.publicKey
517-
}
514+
// Security (finding H1): first-wins on the public key. See `applyInboundPublicKey`.
515+
fetchedNode[0].user?.applyInboundPublicKey(nodeInfoMessage.user.publicKey, nodeNum: Int64(nodeInfoMessage.num))
518516
if let user = fetchedNode.first?.user {
519517
let fetchHwModel2 = Int64(user.hwModelId)
520518
let hwDescriptor2 = FetchDescriptor<DeviceHardwareEntity>(
@@ -546,10 +544,8 @@ extension MeshPackets {
546544
let containsRole = roles.contains(Int(fetchedNode[0].user?.role ?? -1))
547545
fetchedNode[0].user?.unmessagable = containsRole
548546
}
549-
if !userMessage.publicKey.isEmpty {
550-
fetchedNode[0].user?.pkiEncrypted = true
551-
fetchedNode[0].user?.publicKey = userMessage.publicKey
552-
}
547+
// Security (finding H1): first-wins on the public key. See `applyInboundPublicKey`.
548+
fetchedNode[0].user?.applyInboundPublicKey(userMessage.publicKey, nodeNum: Int64(packet.from))
553549
if packet.hopStart != 0 && packet.hopLimit <= packet.hopStart {
554550
fetchedNode[0].hopsAway = Int32(packet.hopStart - packet.hopLimit)
555551
}

0 commit comments

Comments
 (0)