VYPR
Medium severity4.6NVD Advisory· Published Apr 28, 2026· Updated Apr 30, 2026

CVE-2026-41398

CVE-2026-41398

Description

OpenClaw before 2026.4.2 contains an improper access control vulnerability in the iOS A2UI bridge that treats generic local-network pages as trusted origins. Attackers can inject unauthorized agent.request runs by loading attacker-controlled pages from local-network or tailnet hosts, polluting session state and consuming budget.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.22026.4.2

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.2

Patches

1
49d08382a90f

iOS: restrict A2UI action dispatch to trusted canvas URLs (#58471)

https://github.com/openclaw/openclawAgustin RiveraApr 2, 2026via ghsa
6 files changed · +86 42
  • apps/ios/Sources/Model/NodeAppModel+Canvas.swift+15 2 modified
    @@ -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,15 +59,15 @@ 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)
             }
     
             // 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)
             }
    
  • apps/ios/Sources/Model/NodeAppModel.swift+5 2 modified
    @@ -851,15 +851,18 @@ 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:
                 self.screen.showDefaultCanvas()
                 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)
    
  • apps/ios/Sources/Screen/ScreenController.swift+45 22 modified
    @@ -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 {
    
  • apps/ios/Sources/Screen/ScreenWebView.swift+1 6 modified
    @@ -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 }
     
    
  • apps/ios/Tests/ScreenControllerTests.swift+19 10 modified
    @@ -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 {
    
  • CHANGELOG.md+1 0 modified
    @@ -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
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.