Skip to content

Harden SSRF guard and public-key substitution defenses#2107

Open
bruschill wants to merge 4 commits into
meshtastic:mainfrom
bruschill:security/ssrf-pki-hardening
Open

Harden SSRF guard and public-key substitution defenses#2107
bruschill wants to merge 4 commits into
meshtastic:mainfrom
bruschill:security/ssrf-pki-hardening

Conversation

@bruschill

@bruschill bruschill commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What changed?

Defense-in-depth hardening for two areas of the map-overlay import and node-ingestion paths. Focused on tightening validation and making an existing trust signal visible; exploit specifics intentionally omitted here.

1. Overlay fetch — MapDataManager.importFromRemote

This path is reachable from the untrusted importGeoJSON deep link, so it now applies stricter validation of the fetch target:

  • Rejects non-http(s) schemes outright.
  • A session delegate re-validates the host on every HTTP redirect hop (primary control).
  • Best-effort check that the connection didn't land on an internal peer; the response is discarded if it did.
  • Expanded the internal/reserved-address denylist (additional reserved IPv4 ranges and the NAT64 well-known prefix).

2. Inbound public-key handling — node ingestion

Aligns all three ingestion paths (UpdateSwiftData NodeInfo + User, MeshPackets NodeInfo) with the project's documented TOFU model:

  • Extracted first-wins handling into UserEntity.applyInboundPublicKey(_:nodeNum:).
  • When a different key arrives for a contact that already has one, the trusted stored key is kept and the change is surfaced in the UI (keyMatch = false, newPublicKey) — driving the existing key-change indicator — instead of being silently dropped on those paths.
  • Fixed a divergent publicKey == nil guard in MeshPackets that didn't flag mismatches.
  • Annotated the local-only write sites (SecurityConfig, NodeBackupManager restore) as intended/exempt, consistent with the documented local trust boundary.

Why did it change?

Both areas were partial-trust boundaries that could be tightened. The overlay fetch is reachable from an untrusted deep link and could be steered toward internal/reserved hosts (SSRF); the redirect-hop host re-validation plus the expanded denylist close that. On the ingestion side, two of the three paths silently dropped a mismatching inbound public key instead of surfacing it, diverging from the documented TOFU/first-wins model — so a key change on those paths was invisible to the user. This unifies the behavior and makes the trust signal visible.

How is this tested?

  • Host/IP denylist suite (internal, reserved, IPv6, IPv4-mapped, NAT64 blocked; public IPs allowed) + a non-http(s) rejection test (replaces a stale test that asserted the opposite contract).
  • Suite for the key-handling helper (store-first, empty-stored-as-no-key, ignore-empty-inbound, matching no-op, and mismatch keeps the stored key + sets the UI flags).
  • Verified with xcodebuild build-for-testing + targeted test runs — all passing.

Detailed reproduction/threat notes for the overlay-fetch change are being shared privately per the project's security policy rather than in this description.

Screenshots/Videos (when applicable)

N/A — no UI layout change. The key-change path drives the existing key-mismatch indicator; no new screens or labels.

Checklist

  • My code adheres to the project's coding and style guidelines.
  • I have conducted a self-review of my code.
  • I have commented my code, particularly in complex areas.
  • I have verified whether these changes require updates to the in-app documentation under docs/user/ or docs/developer/. No doc update is needed (no user-visible UI/label/navigation change) — skip-docs-check label applies.
  • I have tested the change to ensure that it works as intended.

Summary by CodeRabbit

  • Security
    • Remote map imports are restricted to secure HTTP/HTTPS sources and block internal/private/unsafe destinations (including hardened redirect and DNS/host validation).
    • Downloads are now streamed with stricter validation (HTTP success required, enforced size limits) to reduce unsafe content import risk.
    • Mesh contact public-key handling now uses “first-wins”: the first valid key is kept; mismatching keys are rejected and surfaced.
  • Bug Fixes
    • Prevented file:// (and other unsupported URL schemes) from being used for remote map imports.
  • Tests
    • Added coverage for remote import SSRF defenses and public-key first-wins behavior.

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.
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Remote 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.

Changes

Remote import security

Layer / File(s) Summary
Remote URL validation and download flow
Meshtastic/Helpers/MapDataManager.swift, MeshtasticTests/MapDataManagerTests.swift
Remote imports reject non-HTTP(S) URLs, resolve and block internal destinations, validate responses and download size, and continue through temporary-file processing. Tests cover local-file rejection and host validation.
Redirect and peer protection
Meshtastic/Helpers/MapDataManager.swift, MeshtasticTests/SSRFGuardDelegateTests.swift
SSRFGuardDelegate blocks disallowed redirect hosts and detects disallowed remote peers after connection metrics are collected, with redirect tests covering private, metadata, loopback, and public targets.

Inbound public-key security

Layer / File(s) Summary
First-wins public-key policy
Meshtastic/Model/UserEntity.swift, MeshtasticTests/UserEntityPublicKeyTests.swift
applyInboundPublicKey distinguishes empty, stored, matching, and mismatching keys, preserving the original key on mismatch and recording security state.
Packet and persistence integration
Meshtastic/Helpers/MeshPackets.swift, Meshtastic/Persistence/UpdateSwiftData.swift, Meshtastic/Persistence/NodeBackupManager+Import.swift, Meshtastic/Views/Settings/Config/SecurityConfig.swift
Node and SwiftData updates use the shared key policy; comments distinguish restore and local-key changes.

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
Loading

Possibly related PRs

Poem

I thumped my paws on guarded streams,
And watched old keys stay true;
Bad hosts bounced from rabbit gates,
While first keys safely grew.
Hop, hop—security blooms!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main SSRF and public-key handling hardening.
Description check ✅ Passed The description follows the required template and covers what changed, why, testing, screenshots, and checklist items.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
MeshtasticTests/MapDataManagerTests.swift (1)

139-145: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add 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 win

Prefer configuration injection here. session: injectedSession bypasses SSRFGuardDelegate, so a caller can skip redirect and peer checks entirely. If this hook is only for tests, pass a URLSessionConfiguration/URLProtocol stub and always construct the session with guardDelegate.

🤖 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

📥 Commits

Reviewing files that changed from the base of the PR and between e531f15 and 4c679be.

📒 Files selected for processing (8)
  • Meshtastic/Helpers/MapDataManager.swift
  • Meshtastic/Helpers/MeshPackets.swift
  • Meshtastic/Model/UserEntity.swift
  • Meshtastic/Persistence/NodeBackupManager+Import.swift
  • Meshtastic/Persistence/UpdateSwiftData.swift
  • Meshtastic/Views/Settings/Config/SecurityConfig.swift
  • MeshtasticTests/MapDataManagerTests.swift
  • MeshtasticTests/UserEntityPublicKeyTests.swift

Comment thread Meshtastic/Helpers/MapDataManager.swift Outdated
Comment thread Meshtastic/Helpers/MapDataManager.swift
Comment thread Meshtastic/Helpers/MapDataManager.swift
Comment thread MeshtasticTests/MapDataManagerTests.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.
@bruschill bruschill added the skip-docs-check Use this label to skip the automatic docs audit label Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-docs-check Use this label to skip the automatic docs audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants