Skip to content

Commit ebae6f9

Browse files
authored
fix(shared): reject insecure non-loopback gateway deep links (#21970)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 279173c Co-authored-by: mbelinky <[email protected]> Co-authored-by: mbelinky <[email protected]> Reviewed-by: @mbelinky
1 parent 8fa46d7 commit ebae6f9

File tree

4 files changed

+158
-0
lines changed

4 files changed

+158
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- 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.
4848
- 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.
4949
- iOS/Security: force `https://` for non-loopback manual gateway hosts during iOS onboarding to block insecure remote transport URLs. (#21969) Thanks @mbelinky.
50+
- Shared/Security: reject insecure deep links that use `ws://` non-loopback gateway URLs to prevent plaintext remote websocket configuration. (#21970) Thanks @mbelinky.
5051
- CLI/Onboarding: fix Anthropic-compatible custom provider verification by normalizing base URLs to avoid duplicate `/v1` paths during setup checks. (#21336) Thanks @17jmumford.
5152
- 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.
5253

apps/ios/Tests/DeepLinkParserTests.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ import Testing
8585
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
8686
}
8787

88+
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
89+
let url = URL(
90+
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!
91+
#expect(DeepLinkParser.parse(url) == nil)
92+
}
93+
94+
@Test func parseGatewayLinkRejectsInsecurePrefixBypassHost() {
95+
let url = URL(
96+
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
97+
#expect(DeepLinkParser.parse(url) == nil)
98+
}
99+
88100
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
89101
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
90102
let encoded = Data(payload.utf8)
@@ -124,4 +136,46 @@ import Testing
124136
token: "tok",
125137
password: nil))
126138
}
139+
140+
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
141+
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
142+
let encoded = Data(payload.utf8)
143+
.base64EncodedString()
144+
.replacingOccurrences(of: "+", with: "-")
145+
.replacingOccurrences(of: "/", with: "_")
146+
.replacingOccurrences(of: "=", with: "")
147+
148+
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
149+
#expect(link == nil)
150+
}
151+
152+
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
153+
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
154+
let encoded = Data(payload.utf8)
155+
.base64EncodedString()
156+
.replacingOccurrences(of: "+", with: "-")
157+
.replacingOccurrences(of: "/", with: "_")
158+
.replacingOccurrences(of: "=", with: "")
159+
160+
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
161+
#expect(link == nil)
162+
}
163+
164+
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
165+
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
166+
let encoded = Data(payload.utf8)
167+
.base64EncodedString()
168+
.replacingOccurrences(of: "+", with: "-")
169+
.replacingOccurrences(of: "/", with: "_")
170+
.replacingOccurrences(of: "=", with: "")
171+
172+
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
173+
174+
#expect(link == .init(
175+
host: "127.0.0.1",
176+
port: 18789,
177+
tls: false,
178+
token: "tok",
179+
password: nil))
180+
}
127181
}

apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Network
23

34
public enum DeepLinkRoute: Sendable, Equatable {
45
case agent(AgentDeepLink)
@@ -20,6 +21,40 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
2021
self.password = password
2122
}
2223

24+
fileprivate static func isLoopbackHost(_ raw: String) -> Bool {
25+
var host = raw
26+
.trimmingCharacters(in: .whitespacesAndNewlines)
27+
.lowercased()
28+
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
29+
if host.hasSuffix(".") {
30+
host.removeLast()
31+
}
32+
if let zoneIndex = host.firstIndex(of: "%") {
33+
host = String(host[..<zoneIndex])
34+
}
35+
if host.isEmpty {
36+
return false
37+
}
38+
if host == "localhost" || host == "0.0.0.0" || host == "::" {
39+
return true
40+
}
41+
42+
if let ipv4 = IPv4Address(host) {
43+
return ipv4.rawValue.first == 127
44+
}
45+
if let ipv6 = IPv6Address(host) {
46+
let bytes = Array(ipv6.rawValue)
47+
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
48+
if isV6Loopback {
49+
return true
50+
}
51+
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
52+
return isMappedV4 && bytes[12] == 127
53+
}
54+
55+
return false
56+
}
57+
2358
public var websocketURL: URL? {
2459
let scheme = self.tls ? "wss" : "ws"
2560
return URL(string: "\(scheme)://\(self.host):\(self.port)")
@@ -35,7 +70,11 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
3570
else { return nil }
3671

3772
let scheme = (parsed.scheme ?? "ws").lowercased()
73+
guard scheme == "ws" || scheme == "wss" else { return nil }
3874
let tls = scheme == "wss"
75+
if !tls, !Self.isLoopbackHost(hostname) {
76+
return nil
77+
}
3978
let port = parsed.port ?? (tls ? 443 : 18789)
4079
let token = json["token"] as? String
4180
let password = json["password"] as? String
@@ -128,6 +167,9 @@ public enum DeepLinkParser {
128167
}
129168
let port = query["port"].flatMap { Int($0) } ?? 18789
130169
let tls = (query["tls"] as NSString?)?.boolValue ?? false
170+
if !tls, !GatewayConnectDeepLink.isLoopbackHost(hostParam) {
171+
return nil
172+
}
131173
return .gateway(
132174
.init(
133175
host: hostParam,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Foundation
2+
import OpenClawKit
3+
import Testing
4+
5+
@Suite struct DeepLinksSecurityTests {
6+
@Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() {
7+
let url = URL(
8+
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!
9+
#expect(DeepLinkParser.parse(url) == nil)
10+
}
11+
12+
@Test func gatewayDeepLinkRejectsInsecurePrefixBypassHost() {
13+
let url = URL(
14+
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
15+
#expect(DeepLinkParser.parse(url) == nil)
16+
}
17+
18+
@Test func gatewayDeepLinkAllowsLoopbackWs() {
19+
let url = URL(
20+
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
21+
#expect(
22+
DeepLinkParser.parse(url) == .gateway(
23+
.init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil)))
24+
}
25+
26+
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
27+
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
28+
let encoded = Data(payload.utf8)
29+
.base64EncodedString()
30+
.replacingOccurrences(of: "+", with: "-")
31+
.replacingOccurrences(of: "/", with: "_")
32+
.replacingOccurrences(of: "=", with: "")
33+
#expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil)
34+
}
35+
36+
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
37+
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
38+
let encoded = Data(payload.utf8)
39+
.base64EncodedString()
40+
.replacingOccurrences(of: "+", with: "-")
41+
.replacingOccurrences(of: "/", with: "_")
42+
.replacingOccurrences(of: "=", with: "")
43+
#expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil)
44+
}
45+
46+
@Test func setupCodeAllowsLoopbackWs() {
47+
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
48+
let encoded = Data(payload.utf8)
49+
.base64EncodedString()
50+
.replacingOccurrences(of: "+", with: "-")
51+
.replacingOccurrences(of: "/", with: "_")
52+
.replacingOccurrences(of: "=", with: "")
53+
#expect(
54+
GatewayConnectDeepLink.fromSetupCode(encoded) == .init(
55+
host: "127.0.0.1",
56+
port: 18789,
57+
tls: false,
58+
token: "tok",
59+
password: nil))
60+
}
61+
}

0 commit comments

Comments
 (0)