diff --git a/CHANGELOG.md b/CHANGELOG.md index 73657ca00c3..25f7e908560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit. - Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang. - Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit. +- iOS/canvas: restrict A2UI bridge trust to the bundled scaffold and exact capability-backed remote canvas URLs, so generic `canvas.navigate` and `canvas.present` loads no longer gain action-dispatch authority. (#58471) Thanks @eleqtrizit. ## 2026.4.2-beta.1 diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift index 028983d1a5b..668cd3a8245 100644 --- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift +++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift @@ -33,6 +33,19 @@ extension NodeAppModel { return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios" } + /// Normalize a URL string for trust comparison: lowercase scheme/host and strip fragment. + /// This matches the normalization applied by ScreenController.isTrustedCanvasUIURL so that + /// SPA hash-routing fragments and scheme/host casing do not silently prevent trust being set. + static func normalizeURLForTrustComparison(_ raw: String) -> String { + guard let url = URL(string: raw), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return raw } + components.fragment = nil + components.scheme = components.scheme?.lowercased() + components.host = components.host?.lowercased() + return components.url?.absoluteString ?? raw + } + func showA2UIOnConnectIfNeeded() async { await MainActor.run { // Keep the bundled home canvas as the default connected view. @@ -46,7 +59,7 @@ extension NodeAppModel { guard let initialUrl = await self.resolveA2UIHostURLWithCapabilityRefresh() else { return .hostNotConfigured } - self.screen.navigate(to: initialUrl) + self.screen.navigate(to: initialUrl, trustA2UIActions: true) if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { return .ready(initialUrl) } @@ -54,7 +67,7 @@ extension NodeAppModel { // First render can fail when scoped capability rotates between reconnects. guard await self.gatewaySession.refreshNodeCanvasCapability() else { return .hostUnavailable } guard let refreshedUrl = await self.resolveA2UIHostURL() else { return .hostUnavailable } - self.screen.navigate(to: refreshedUrl) + self.screen.navigate(to: refreshedUrl, trustA2UIActions: true) if await self.screen.waitForA2UIReady(timeoutMs: timeoutMs) { return .ready(refreshedUrl) } diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 998b0ef70c5..5c72ceafc93 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -851,7 +851,8 @@ final class NodeAppModel { if url.isEmpty { self.screen.showDefaultCanvas() } else { - self.screen.navigate(to: url) + let trustedA2UIURL = await self.resolveA2UIHostURL() + self.screen.navigate(to: url, trustA2UIActions: trustedA2UIURL == Self.normalizeURLForTrustComparison(url)) } return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.hide.rawValue: @@ -859,7 +860,9 @@ final class NodeAppModel { return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.navigate.rawValue: let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON) - self.screen.navigate(to: params.url) + let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines) + let trustedA2UIURL = await self.resolveA2UIHostURL() + self.screen.navigate(to: trimmedURL, trustA2UIActions: trustedA2UIURL == Self.normalizeURLForTrustComparison(trimmedURL)) return BridgeInvokeResponse(id: req.id, ok: true) case OpenClawCanvasCommand.evalJS.rawValue: let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON) diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 4c9f3ff5085..f476dd3c397 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -7,6 +7,7 @@ import WebKit @Observable final class ScreenController { private weak var activeWebView: WKWebView? + private var trustedRemoteA2UIURL: URL? var urlString: String = "" var errorText: String? @@ -26,10 +27,11 @@ final class ScreenController { self.reload() } - func navigate(to urlString: String) { + func navigate(to urlString: String, trustA2UIActions: Bool = false) { let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { self.urlString = "" + self.trustedRemoteA2UIURL = nil self.reload() return } @@ -43,6 +45,7 @@ final class ScreenController { return } self.urlString = (trimmed == "/" ? "" : trimmed) + self.trustedRemoteA2UIURL = trustA2UIActions ? Self.normalizeTrustedRemoteA2UIURL(from: trimmed) : nil self.reload() } @@ -72,6 +75,7 @@ final class ScreenController { func showDefaultCanvas() { self.urlString = "" + self.trustedRemoteA2UIURL = nil self.reload() } @@ -237,28 +241,17 @@ final class ScreenController { subdirectory: "CanvasScaffold") func isTrustedCanvasUIURL(_ url: URL) -> Bool { - guard url.isFileURL else { return false } - let std = url.standardizedFileURL - if let expected = Self.canvasScaffoldURL, - std == expected.standardizedFileURL - { - return true + if url.isFileURL { + let std = url.standardizedFileURL + if let expected = Self.canvasScaffoldURL, + std == expected.standardizedFileURL + { + return true + } + return false } - return false - } - - private func applyScrollBehavior() { - guard let webView = self.activeWebView else { return } - let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) - let allowScroll = !trimmed.isEmpty - let scrollView = webView.scrollView - // Default canvas needs raw touch events; external pages should scroll. - scrollView.isScrollEnabled = allowScroll - scrollView.bounces = allowScroll - } - - func isLocalNetworkCanvasURL(_ url: URL) -> Bool { - LocalNetworkURLSupport.isLocalNetworkHTTPURL(url) + guard let trusted = self.trustedRemoteA2UIURL else { return false } + return Self.normalizeTrustedRemoteA2UIURL(from: url) == trusted } nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? { @@ -278,6 +271,36 @@ final class ScreenController { } return nil } + + private func applyScrollBehavior() { + guard let webView = self.activeWebView else { return } + let trimmed = self.urlString.trimmingCharacters(in: .whitespacesAndNewlines) + let allowScroll = !trimmed.isEmpty + let scrollView = webView.scrollView + // Default canvas needs raw touch events; external pages should scroll. + scrollView.isScrollEnabled = allowScroll + scrollView.bounces = allowScroll + } + + private static func normalizeTrustedRemoteA2UIURL(from raw: String) -> URL? { + guard let url = URL(string: raw) else { return nil } + return self.normalizeTrustedRemoteA2UIURL(from: url) + } + + private static func normalizeTrustedRemoteA2UIURL(from url: URL) -> URL? { + guard !url.isFileURL else { return nil } + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return nil + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return nil + } + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.scheme = scheme + components?.host = host.lowercased() + components?.fragment = nil + return components?.url + } } extension Double { diff --git a/apps/ios/Sources/Screen/ScreenWebView.swift b/apps/ios/Sources/Screen/ScreenWebView.swift index 61f9af6515c..48721442065 100644 --- a/apps/ios/Sources/Screen/ScreenWebView.swift +++ b/apps/ios/Sources/Screen/ScreenWebView.swift @@ -180,12 +180,7 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan guard let controller else { return } guard let url = message.webView?.url else { return } - if url.isFileURL { - guard controller.isTrustedCanvasUIURL(url) else { return } - } else { - // For security, only accept actions from local-network pages (e.g. the canvas host). - guard controller.isLocalNetworkCanvasURL(url) else { return } - } + guard controller.isTrustedCanvasUIURL(url) else { return } guard let body = ScreenController.parseA2UIActionBody(message.body) else { return } diff --git a/apps/ios/Tests/ScreenControllerTests.swift b/apps/ios/Tests/ScreenControllerTests.swift index d0e47c84fb3..d97c319f8bd 100644 --- a/apps/ios/Tests/ScreenControllerTests.swift +++ b/apps/ios/Tests/ScreenControllerTests.swift @@ -66,17 +66,26 @@ private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoo } } - @Test @MainActor func localNetworkCanvasURLsAreAllowed() { + @Test @MainActor func trustedRemoteA2UIURLMustMatchExactly() { let screen = ScreenController() - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://openclaw.local:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://peters-mac-studio-1:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://peters-mac-studio-1.ts.net:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18789/")!) == true) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18789/")!) == true) // Tailscale CGNAT - #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false) - #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false) + let trusted = "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios" + screen.navigate(to: trusted, trustA2UIActions: true) + + #expect(screen.isTrustedCanvasUIURL(URL(string: trusted)!) == true) + // Fragment differences must not affect trust (SPA hash routing). + #expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios#step2")!) == true) + #expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=android")!) == false) + #expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/canvas/")!) == false) + #expect(screen.isTrustedCanvasUIURL(URL(string: "https://evil.ts.net:18789/__openclaw__/a2ui/?platform=ios")!) == false) + #expect(screen.isTrustedCanvasUIURL(URL(string: "http://192.168.0.10:18789/")!) == false) + } + + @Test @MainActor func genericNavigationClearsTrustedRemoteA2UIURL() { + let screen = ScreenController() + screen.navigate(to: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios", trustA2UIActions: true) + screen.navigate(to: "https://evil.ts.net:18789/") + + #expect(screen.isTrustedCanvasUIURL(URL(string: "https://node.ts.net:18789/__openclaw__/a2ui/?platform=ios")!) == false) } @Test func parseA2UIActionBodyAcceptsJSONString() throws {