mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 08:52:45 +00:00
fix(shared): reject insecure non-loopback gateway deep links (#21970)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 279173c7db
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -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/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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,18 @@ import Testing
|
|||||||
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
|
.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() {
|
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
||||||
let encoded = Data(payload.utf8)
|
let encoded = Data(payload.utf8)
|
||||||
@@ -124,4 +136,46 @@ import Testing
|
|||||||
token: "tok",
|
token: "tok",
|
||||||
password: nil))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
public enum DeepLinkRoute: Sendable, Equatable {
|
public enum DeepLinkRoute: Sendable, Equatable {
|
||||||
case agent(AgentDeepLink)
|
case agent(AgentDeepLink)
|
||||||
@@ -20,6 +21,40 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|||||||
self.password = password
|
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[..<zoneIndex])
|
||||||
|
}
|
||||||
|
if host.isEmpty {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if host == "localhost" || host == "0.0.0.0" || host == "::" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ipv4 = IPv4Address(host) {
|
||||||
|
return ipv4.rawValue.first == 127
|
||||||
|
}
|
||||||
|
if let ipv6 = IPv6Address(host) {
|
||||||
|
let bytes = Array(ipv6.rawValue)
|
||||||
|
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
|
||||||
|
if isV6Loopback {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
|
||||||
|
return isMappedV4 && bytes[12] == 127
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
public var websocketURL: URL? {
|
public var websocketURL: URL? {
|
||||||
let scheme = self.tls ? "wss" : "ws"
|
let scheme = self.tls ? "wss" : "ws"
|
||||||
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
||||||
@@ -35,7 +70,11 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
let scheme = (parsed.scheme ?? "ws").lowercased()
|
let scheme = (parsed.scheme ?? "ws").lowercased()
|
||||||
|
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||||
let tls = scheme == "wss"
|
let tls = scheme == "wss"
|
||||||
|
if !tls, !Self.isLoopbackHost(hostname) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
let port = parsed.port ?? (tls ? 443 : 18789)
|
let port = parsed.port ?? (tls ? 443 : 18789)
|
||||||
let token = json["token"] as? String
|
let token = json["token"] as? String
|
||||||
let password = json["password"] as? String
|
let password = json["password"] as? String
|
||||||
@@ -128,6 +167,9 @@ public enum DeepLinkParser {
|
|||||||
}
|
}
|
||||||
let port = query["port"].flatMap { Int($0) } ?? 18789
|
let port = query["port"].flatMap { Int($0) } ?? 18789
|
||||||
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
||||||
|
if !tls, !GatewayConnectDeepLink.isLoopbackHost(hostParam) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return .gateway(
|
return .gateway(
|
||||||
.init(
|
.init(
|
||||||
host: hostParam,
|
host: hostParam,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import OpenClawKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite struct DeepLinksSecurityTests {
|
||||||
|
@Test func gatewayDeepLinkRejectsInsecureNonLoopbackWs() {
|
||||||
|
let url = URL(
|
||||||
|
string: "openclaw://gateway?host=attacker.example&port=18789&tls=0&token=abc")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func gatewayDeepLinkRejectsInsecurePrefixBypassHost() {
|
||||||
|
let url = URL(
|
||||||
|
string: "openclaw://gateway?host=127.attacker.example&port=18789&tls=0&token=abc")!
|
||||||
|
#expect(DeepLinkParser.parse(url) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func gatewayDeepLinkAllowsLoopbackWs() {
|
||||||
|
let url = URL(
|
||||||
|
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
|
||||||
|
#expect(
|
||||||
|
DeepLinkParser.parse(url) == .gateway(
|
||||||
|
.init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
|
||||||
|
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
|
||||||
|
let encoded = Data(payload.utf8)
|
||||||
|
.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
#expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
|
||||||
|
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: "")
|
||||||
|
#expect(GatewayConnectDeepLink.fromSetupCode(encoded) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func setupCodeAllowsLoopbackWs() {
|
||||||
|
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: "")
|
||||||
|
#expect(
|
||||||
|
GatewayConnectDeepLink.fromSetupCode(encoded) == .init(
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 18789,
|
||||||
|
tls: false,
|
||||||
|
token: "tok",
|
||||||
|
password: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user