From 9ace6af3df0709467f9c93b10fc0adcfe0703607 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 18 Dec 2025 23:31:49 +0100 Subject: [PATCH] iOS: allow A2UI actions from local canvas host --- .../ios/Sources/Camera/CameraController.swift | 2 +- .../ios/Sources/Screen/ScreenController.swift | 81 +++++++++++++++---- apps/ios/Tests/ScreenControllerTests.swift | 17 ++++ 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift index 2092274be9..bf8670e475 100644 --- a/apps/ios/Sources/Camera/CameraController.swift +++ b/apps/ios/Sources/Camera/CameraController.swift @@ -214,7 +214,7 @@ actor CameraController { nonisolated static func clampDurationMs(_ ms: Int?) -> Int { let v = ms ?? 3000 // Keep clips short by default; avoid huge base64 payloads on the bridge. - return min(15000, max(250, v)) + return min(60_000, max(250, v)) } private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws { diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift index 7b8de9b38d..9d5643b67b 100644 --- a/apps/ios/Sources/Screen/ScreenController.swift +++ b/apps/ios/Sources/Screen/ScreenController.swift @@ -196,7 +196,7 @@ final class ScreenController { forResource: "index", withExtension: "html") - fileprivate func isTrustedCanvasUIURL(_ url: URL) -> Bool { + func isTrustedCanvasUIURL(_ url: URL) -> Bool { guard url.isFileURL else { return false } let std = url.standardizedFileURL if let expected = Self.canvasScaffoldURL, @@ -211,6 +211,64 @@ final class ScreenController { } return false } + + func isLocalNetworkCanvasURL(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { + return false + } + guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else { + return false + } + if host == "localhost" { return true } + if host.hasSuffix(".local") { return true } + if let ipv4 = Self.parseIPv4(host) { + return Self.isLocalNetworkIPv4(ipv4) + } + return false + } + + private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes: [UInt8] = parts.compactMap { UInt8($0) } + guard bytes.count == 4 else { return nil } + return (bytes[0], bytes[1], bytes[2], bytes[3]) + } + + private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool { + let (a, b, _, _) = ip + // 10.0.0.0/8 + if a == 10 { return true } + // 172.16.0.0/12 + if a == 172, (16...31).contains(Int(b)) { return true } + // 192.168.0.0/16 + if a == 192, b == 168 { return true } + // 127.0.0.0/8 + if a == 127 { return true } + // 169.254.0.0/16 (link-local) + if a == 169, b == 254 { return true } + // Tailscale: 100.64.0.0/10 + if a == 100, (64...127).contains(Int(b)) { return true } + return false + } + + static func parseA2UIActionBody(_ body: Any) -> [String: Any]? { + if let dict = body as? [String: Any] { return dict.isEmpty ? nil : dict } + if let str = body as? String, + let data = str.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + return json.isEmpty ? nil : json + } + if let dict = body as? [AnyHashable: Any] { + let mapped = dict.reduce(into: [String: Any]()) { acc, pair in + guard let key = pair.key as? String else { return } + acc[key] = pair.value + } + return mapped.isEmpty ? nil : mapped + } + return nil + } } extension Double { @@ -259,20 +317,15 @@ private final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHan guard message.name == Self.messageName else { return } guard let controller else { return } - // Only accept actions from local bundled canvas/A2UI content (not arbitrary web pages). - guard let url = message.webView?.url, controller.isTrustedCanvasUIURL(url) 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 } + } - let body: [String: Any] = { - if let dict = message.body as? [String: Any] { return dict } - if let dict = message.body as? [AnyHashable: Any] { - return dict.reduce(into: [String: Any]()) { acc, pair in - guard let key = pair.key as? String else { return } - acc[key] = pair.value - } - } - return [:] - }() - guard !body.isEmpty else { return } + guard let body = ScreenController.parseA2UIActionBody(message.body) else { return } controller.onA2UIAction?(body) } diff --git a/apps/ios/Tests/ScreenControllerTests.swift b/apps/ios/Tests/ScreenControllerTests.swift index 09a6e323be..775f5edcf9 100644 --- a/apps/ios/Tests/ScreenControllerTests.swift +++ b/apps/ios/Tests/ScreenControllerTests.swift @@ -41,4 +41,21 @@ import WebKit } } } + + @Test @MainActor func localNetworkCanvasURLsAreAllowed() { + let screen = ScreenController() + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://localhost:18793/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://clawd.local:18793/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://192.168.0.10:18793/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://10.0.0.10:18793/")!) == true) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://100.123.224.76:18793/")!) == true) // Tailscale CGNAT + #expect(screen.isLocalNetworkCanvasURL(URL(string: "https://example.com/")!) == false) + #expect(screen.isLocalNetworkCanvasURL(URL(string: "http://8.8.8.8/")!) == false) + } + + @Test func parseA2UIActionBodyAcceptsJSONString() throws { + let body = ScreenController.parseA2UIActionBody("{\"userAction\":{\"name\":\"hello\"}}") + let userAction = try #require(body?["userAction"] as? [String: Any]) + #expect(userAction["name"] as? String == "hello") + } }