diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd748c1377..29ad6ee91d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/Tool results: serialize tool-result delivery and keep the delivery chain progressing after individual failures so concurrent tool outputs preserve user-visible ordering. (#21231) thanks @ahdernasr. - Auto-reply/Prompt caching: restore prefix-cache stability by keeping inbound system metadata session-stable and moving per-message IDs (`message_id`, `message_id_full`, `reply_to_id`, `sender_id`) into untrusted conversation context. (#20597) Thanks @anisoptera. - iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky. +- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky. - CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford. - Security/Dependencies: bump transitive `hono` usage to `4.11.10` to incorporate timing-safe authentication comparison hardening for `basicAuth`/`bearerAuth` (`GHSA-gq3j-xvxp-8hrf`). Thanks @vincentkoc. diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index ea8b2a81203..51ef9547a10 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -85,6 +85,18 @@ import Testing .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) } + @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() { + let url = URL( + string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() { + let url = URL( + string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")! + #expect(DeepLinkParser.parse(url) == nil) + } + @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# let encoded = Data(payload.utf8) @@ -124,4 +136,46 @@ import Testing token: "tok", password: nil)) } + + @Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { + let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + #expect(link == nil) + } + + @Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { + let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + #expect(link == nil) + } + + @Test func parseGatewaySetupCodeAllowsLoopbackWs() { + let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let encoded = Data(payload.utf8) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + let link = GatewayConnectDeepLink.fromSetupCode(encoded) + + #expect(link == .init( + host: "127.0.0.1", + port: 18789, + tls: false, + token: "tok", + password: nil)) + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 30606ca2671..50714884619 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -1,4 +1,5 @@ import Foundation +import Network public enum DeepLinkRoute: Sendable, Equatable { case agent(AgentDeepLink) @@ -20,6 +21,40 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { self.password = password } + fileprivate static func isLoopbackHost(_ raw: String) -> Bool { + var host = raw + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host.hasSuffix(".") { + host.removeLast() + } + if let zoneIndex = host.firstIndex(of: "%") { + host = String(host[..