Harden inbound public-key handling (first-wins / TOFU)#2107
Conversation
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.
📝 WalkthroughWalkthroughRemote map imports now enforce HTTP(S) destinations and SSRF protections. Inbound mesh public-key updates use shared first-wins handling across packet and persistence paths, with mismatch tracking and security logging. ChangesRemote import security
Inbound public-key security
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Client
participant MapDataManager
participant URLSession
participant SSRFGuardDelegate
Client->>MapDataManager: Request remote map import
MapDataManager->>URLSession: Stream guarded HTTP(S) download
URLSession->>SSRFGuardDelegate: Validate redirects and peer metrics
SSRFGuardDelegate-->>URLSession: Allow or reject destination
URLSession-->>MapDataManager: Return bounded response
MapDataManager-->>Client: Import metadata or disallowed-host error
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
MeshtasticTests/MapDataManagerTests.swift (1)
139-145: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winAdd a public IPv6 control case.
This allow-list test covers only
AF_INET. Include a globally routable IPv6 literal to ensure IPv6 byte extraction and denylist changes do not reject all IPv6 destinations.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@MeshtasticTests/MapDataManagerTests.swift` around lines 139 - 145, Add a globally routable IPv6 literal to the arguments of allowsPublicHosts in the public-host allow-list test, preserving the existing IPv4 cases and expectation so IPv6 destinations are verified as allowed.Meshtastic/Helpers/MapDataManager.swift (1)
112-155: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winPrefer configuration injection here.
session: injectedSessionbypassesSSRFGuardDelegate, so a caller can skip redirect and peer checks entirely. If this hook is only for tests, pass aURLSessionConfiguration/URLProtocolstub and always construct the session withguardDelegate.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Helpers/MapDataManager.swift` around lines 112 - 155, Update importFromRemote so callers cannot supply a URLSession that bypasses SSRFGuardDelegate; replace the injectedSession hook with test-oriented URLSessionConfiguration/URLProtocol injection, while always constructing the session with guardDelegate and retaining the existing cleanup behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Meshtastic/Helpers/MapDataManager.swift`:
- Around line 595-619: Update isDisallowedIPv6 to reject deprecated site-local
fec0::/10 and multicast ff00::/8 addresses, alongside the existing
special-purpose range checks. Add regression cases covering representative
addresses from both ranges and verify they are classified as disallowed.
- Line 157: Update the remote download flow around the session.data call to use
streaming via bytes(from:) or an equivalent delegate, accumulating the response
bytes while enforcing maxFileSize during reads. Stop reading and reject the
download immediately once the accumulated size exceeds maxFileSize, then
preserve the existing response handling for valid files.
- Around line 669-681: Update the transaction loop in
urlSession(_:task:didFinishCollecting:) to skip transactions whose
isProxyConnection is true before reading remoteAddress or calling
MapDataManager.isDisallowedHost. Continue evaluating non-proxy transactions with
the existing peer validation and flagging behavior.
In `@MeshtasticTests/MapDataManagerTests.swift`:
- Around line 93-104: The rejectsLocalFileURL test should verify the specific
MapDataError.disallowedHost case rather than accepting any MapDataError. Update
the `#expect` assertion around MapDataManager.importFromRemote to pattern-match
the thrown error as .disallowedHost while preserving the existing file URL setup
and session.
---
Nitpick comments:
In `@Meshtastic/Helpers/MapDataManager.swift`:
- Around line 112-155: Update importFromRemote so callers cannot supply a
URLSession that bypasses SSRFGuardDelegate; replace the injectedSession hook
with test-oriented URLSessionConfiguration/URLProtocol injection, while always
constructing the session with guardDelegate and retaining the existing cleanup
behavior.
In `@MeshtasticTests/MapDataManagerTests.swift`:
- Around line 139-145: Add a globally routable IPv6 literal to the arguments of
allowsPublicHosts in the public-host allow-list test, preserving the existing
IPv4 cases and expectation so IPv6 destinations are verified as allowed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 3d934745-06b7-4035-b067-f3a2595b5c8d
📒 Files selected for processing (8)
Meshtastic/Helpers/MapDataManager.swiftMeshtastic/Helpers/MeshPackets.swiftMeshtastic/Model/UserEntity.swiftMeshtastic/Persistence/NodeBackupManager+Import.swiftMeshtastic/Persistence/UpdateSwiftData.swiftMeshtastic/Views/Settings/Config/SecurityConfig.swiftMeshtasticTests/MapDataManagerTests.swiftMeshtasticTests/UserEntityPublicKeyTests.swift
Make SSRFGuardDelegate internal (was private) so its redirect control can be driven directly, and add SSRFGuardDelegateRedirectTests: a 302 to loopback / link-local (169.254.169.254) / private LAN is refused, a 302 to a public host is followed, and the peer flag defaults false. Hermetic (IP literals only).
- Stream remote GeoJSON downloads via bytes(from:) and abort once the running total exceeds maxFileSize, with an early reject on an honest oversized Content-Length (avoids buffering an attacker-sized body). - Inject only a URLSessionConfiguration so the session is always built with SSRFGuardDelegate; callers can no longer bypass the guard. - Reject deprecated site-local (fec0::/10) and multicast (ff00::/8) IPv6. - Skip proxied transactions before the remoteAddress peer check. - Tests: assert .disallowedHost specifically for file:// rejection, add public IPv6 control cases, stub advertises Content-Length.
…dening # Conflicts: # Meshtastic/Helpers/MapDataManager.swift # MeshtasticTests/MapDataManagerTests.swift
…fetch Deletes the `meshtastic:///importGeoJSON?url=<...>` deep link, which was the only untrusted-input entry point into MapDataManager.importFromRemote — a server-side request forgery (SSRF) surface that fetched an attacker-supplied URL. Rather than harden the fetch, this removes the surface entirely: - Router: drop the /importGeoJSON dispatch and routeImportGeoJSON(). - MapDataManager: remove importFromRemote() — the deep link was its only production caller; importFromString() (Site Planner) and processUploadedFile() (Open In / Share Sheet file import) are unaffected. - Tests: remove the importFromRemote suite and its URLProtocol stub; the importFromString suite is retained. Supersedes the overlay-fetch (SSRF) half of meshtastic#2107; the public-key/TOFU hardening in that PR is unrelated and unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What changed?
Aligns all inbound public-key ingestion paths with the project's documented Trust-On-First-Use (first-wins) model.
UserEntity.applyInboundPublicKey(_:nodeNum:).keyMatch = false,newPublicKey = inbound) — driving the existing key-change indicator — instead of being silently dropped or blindly overwritten.UpdateSwiftData(NodeInfo + User) andMeshPackets(NodeInfo). Fixes a divergentpublicKey == nilguard inMeshPacketsthat didn't flag mismatches.SecurityConfig— user changing their own keypair;NodeBackupManagerrestore) as intended/exempt, consistent with the documented local trust boundary.Why?
Two of the three ingestion paths mishandled a mismatching inbound public key — one silently dropped it, others blindly overwrote the stored key — diverging from the documented TOFU model. That let a mesh/MQTT peer substitute a trusted contact's key (enabling a MITM of PKC direct messages) without the change being visible. This unifies the behavior to first-wins and makes the trust signal visible.
Scope note — SSRF/overlay-fetch changes removed
This PR originally also hardened the map-overlay remote fetch (SSRF) reachable from the
importGeoJSONdeep link. That deep link and itsMapDataManager.importFromRemotemethod have since been deleted entirely in #2129 (merged), which removes the attack surface outright. The SSRF changes here (MapDataManagerredirect/denylist validation,SSRFGuardDelegateTests, and the remote-import test edits) are therefore obsolete and have been dropped — this PR is now public-key / TOFU only. Currentmainhas been merged in to resolve the resulting conflicts.How is this tested?
UserEntityPublicKeyTestscovers the helper: store-first, empty-stored-as-no-key, ignore-empty-inbound, matching no-op, and mismatch keeps the stored key + sets the UI flags (keyMatch = false,newPublicKey).Checklist
skip-docs-checkapplies.