Skip to content

Commit 2edb8c3

Browse files
garthvhclaude
andcommitted
security(map): remove importGeoJSON deep link and unreachable remote 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>
1 parent e531f15 commit 2edb8c3

7 files changed

Lines changed: 104 additions & 156 deletions

File tree

Meshtastic/Helpers/MapDataManager.swift

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -102,38 +102,6 @@ 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 {
109-
guard let url = URL(string: urlString) else {
110-
throw MapDataError.invalidDestination
111-
}
112-
113-
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-
}
117-
118-
let (data, response) = try await session.data(from: url)
119-
if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) {
120-
throw MapDataError.invalidContent
121-
}
122-
guard data.count <= maxFileSize else {
123-
throw MapDataError.fileTooLarge
124-
}
125-
126-
let suggestedName = url.pathExtension.lowercased() == "geojson" || url.pathExtension.lowercased() == "json"
127-
? url.lastPathComponent
128-
: "coverage.geojson"
129-
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathComponent(suggestedName)
130-
try FileManager.default.createDirectory(at: tempURL.deletingLastPathComponent(), withIntermediateDirectories: true)
131-
try data.write(to: tempURL)
132-
defer { try? FileManager.default.removeItem(at: tempURL) }
133-
134-
return try await processUploadedFile(from: tempURL)
135-
}
136-
137105
/// Imports an in-memory GeoJSON string (e.g. the coverage `FeatureCollection` handed back by
138106
/// the Site Planner's native bridge) through the same pipeline as `processUploadedFile`, so it
139107
/// reuses the exact validation + render + styling path with no round-trip to the share sheet.

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

MeshtasticTests/MapDataManagerTests.swift

Lines changed: 0 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -5,105 +5,6 @@ import Testing
55
import Foundation
66
@testable import Meshtastic
77

8-
/// Stubs `URLSession` responses for `MapDataManager.importFromRemote` without touching the network.
9-
private final class GeoJSONStubURLProtocol: URLProtocol {
10-
nonisolated(unsafe) static var statusCode = 200
11-
nonisolated(unsafe) static var responseData: Data = Data()
12-
13-
override class func canInit(with request: URLRequest) -> Bool { true }
14-
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
15-
16-
override func startLoading() {
17-
let response = HTTPURLResponse(url: request.url!, statusCode: Self.statusCode, httpVersion: "HTTP/1.1", headerFields: nil)!
18-
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
19-
client?.urlProtocol(self, didLoad: Self.responseData)
20-
client?.urlProtocolDidFinishLoading(self)
21-
}
22-
23-
override func stopLoading() {}
24-
25-
static func makeSession() -> URLSession {
26-
let config = URLSessionConfiguration.ephemeral
27-
config.protocolClasses = [GeoJSONStubURLProtocol.self]
28-
return URLSession(configuration: config)
29-
}
30-
}
31-
32-
// Serialized: tests share mutable state on `GeoJSONStubURLProtocol` (its stubbed status/data are
33-
// process-global, keyed by class not by request), so concurrent runs race and cross-contaminate.
34-
@Suite("MapDataManager.importFromRemote", .serialized)
35-
struct MapDataManagerImportFromRemoteTests {
36-
37-
private let sampleGeoJSON = Data("""
38-
{ "type": "FeatureCollection", "features": [
39-
{ "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]] },
40-
"properties": { "fill": "#0080ff" } },
41-
{ "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[[2, 2], [3, 2], [3, 3], [2, 2]]] },
42-
"properties": { "fill": "#00ff80" } }
43-
]}
44-
""".utf8)
45-
46-
/// Deletes anything the test imported so repeated runs don't accumulate files in the real Documents
47-
/// directory — `MapDataManager` has no injectable storage root, so cleanup has to happen here.
48-
private func cleanUp(_ manager: MapDataManager, _ metadata: MapDataMetadata) async {
49-
try? await manager.deleteFile(metadata)
50-
}
51-
52-
@Test func downloadsAndImportsValidGeoJSON() async throws {
53-
GeoJSONStubURLProtocol.statusCode = 200
54-
GeoJSONStubURLProtocol.responseData = sampleGeoJSON
55-
56-
let manager = MapDataManager()
57-
let metadata = try await manager.importFromRemote(
58-
urlString: "https://example.com/coverage-\(UUID().uuidString).geojson",
59-
session: GeoJSONStubURLProtocol.makeSession()
60-
)
61-
defer { Task { await cleanUp(manager, metadata) } }
62-
63-
#expect(metadata.overlayCount == 2)
64-
#expect(metadata.format == "geojson")
65-
}
66-
67-
@Test func throwsOnNonSuccessStatusCode() async throws {
68-
GeoJSONStubURLProtocol.statusCode = 404
69-
GeoJSONStubURLProtocol.responseData = Data()
70-
71-
let manager = MapDataManager()
72-
await #expect(throws: Error.self) {
73-
try await manager.importFromRemote(
74-
urlString: "https://example.com/missing.geojson",
75-
session: GeoJSONStubURLProtocol.makeSession()
76-
)
77-
}
78-
}
79-
80-
@Test func throwsOnOversizedDownload() async throws {
81-
GeoJSONStubURLProtocol.statusCode = 200
82-
GeoJSONStubURLProtocol.responseData = Data(repeating: 0, count: 11 * 1024 * 1024) // > 10MB cap
83-
84-
let manager = MapDataManager()
85-
await #expect(throws: MapDataError.self) {
86-
try await manager.importFromRemote(
87-
urlString: "https://example.com/huge.geojson",
88-
session: GeoJSONStubURLProtocol.makeSession()
89-
)
90-
}
91-
}
92-
93-
@Test func readsLocalFileURLWithoutNetworking() async throws {
94-
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("local-\(UUID().uuidString).geojson")
95-
try sampleGeoJSON.write(to: tempURL)
96-
defer { try? FileManager.default.removeItem(at: tempURL) }
97-
98-
let manager = MapDataManager()
99-
// No session is consulted for a `file://` URL — passing the stub session anyway proves that.
100-
let metadata = try await manager.importFromRemote(urlString: tempURL.absoluteString, session: GeoJSONStubURLProtocol.makeSession())
101-
defer { Task { await cleanUp(manager, metadata) } }
102-
103-
#expect(metadata.overlayCount == 2)
104-
}
105-
}
106-
1078
/// Covers `MapDataManager.importFromString` — the in-memory GeoJSON import path used by the
1089
/// Site Planner native bridge (meshtastic/Meshtastic-Apple#2058).
10910
@Suite("MapDataManager.importFromString", .serialized)

build.log

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
objc[69682]: Class XROS1_0SimRuntime is implemented in both /Library/Developer/CoreSimulator/Volumes/xrOS_21N5207f/Library/Developer/CoreSimulator/Profiles/Runtimes/xrOS 1.0.simruntime/Contents/MacOS/xrOS 1.0 (0x1131780e0) and /Library/Developer/CoreSimulator/Volumes/xrOS_21N5165g/Library/Developer/CoreSimulator/Profiles/Runtimes/xrOS 1.0.simruntime/Contents/MacOS/xrOS 1.0 (0x1149f00e0). This may cause spurious casting failures and mysterious crashes. One of the duplicates must be removed or renamed.
2+
2026-07-20 07:58:24.560 xcodebuild[69682:3392257] Writing error result bundle to /var/folders/w_/5f3jx8z95rg3lf3p9dcrvlbm0000gn/T/ResultBundle_2026-20-07_07-58-0024.xcresult
3+
xcodebuild: error: Failed to build project Meshtastic with scheme Meshtastic.: This scheme builds an embedded Apple Watch app. watchOS 26.5 must be installed in order to test the scheme

build2.log

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
objc[69702]: Class XROS1_0SimRuntime is implemented in both /Library/Developer/CoreSimulator/Volumes/xrOS_21N5207f/Library/Developer/CoreSimulator/Profiles/Runtimes/xrOS 1.0.simruntime/Contents/MacOS/xrOS 1.0 (0x118e900e0) and /Library/Developer/CoreSimulator/Volumes/xrOS_21N5165g/Library/Developer/CoreSimulator/Profiles/Runtimes/xrOS 1.0.simruntime/Contents/MacOS/xrOS 1.0 (0x118ea40e0). This may cause spurious casting failures and mysterious crashes. One of the duplicates must be removed or renamed.
2+
2026-07-20 07:58:43.339 xcodebuild[69702:3392664] Writing error result bundle to /var/folders/w_/5f3jx8z95rg3lf3p9dcrvlbm0000gn/T/ResultBundle_2026-20-07_07-58-0043.xcresult
3+
xcodebuild: error: Failed to build project Meshtastic with scheme Meshtastic.: This scheme builds an embedded Apple Watch app. watchOS 26.5 must be installed in order to run the scheme

build3.log

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
objc[69724]: Class XROS1_0SimRuntime is implemented in both /Library/Developer/CoreSimulator/Volumes/xrOS_21N5207f/Library/Developer/CoreSimulator/Profiles/Runtimes/xrOS 1.0.simruntime/Contents/MacOS/xrOS 1.0 (0x113d4c0e0) and /Library/Developer/CoreSimulator/Volumes/xrOS_21N5165g/Library/Developer/CoreSimulator/Profiles/Runtimes/xrOS 1.0.simruntime/Contents/MacOS/xrOS 1.0 (0x115efc0e0). This may cause spurious casting failures and mysterious crashes. One of the duplicates must be removed or renamed.
2+
2026-07-20 07:59:07.316 xcodebuild[69724:3393301] Writing error result bundle to /var/folders/w_/5f3jx8z95rg3lf3p9dcrvlbm0000gn/T/ResultBundle_2026-20-07_07-59-0007.xcresult
3+
xcodebuild: error: Failed to build project Meshtastic with scheme Meshtastic.: This scheme builds an embedded Apple Watch app. watchOS 26.5 must be installed in order to run the scheme

0 commit comments

Comments
 (0)