From 617e38cec04df983fd6468d731fa0c2d8d370ff2 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Fri, 20 Feb 2026 18:41:11 -0700 Subject: [PATCH] Security/macos: enforce wss for non-loopback direct gateway --- apps/macos/Sources/OpenClaw/AppState.swift | 3 +-- .../OpenClaw/GatewayDiscoveryHelpers.swift | 15 ++++++++++++++- apps/macos/Sources/OpenClaw/GeneralSettings.swift | 14 +++++++------- .../GatewayDiscoveryHelpersTests.swift | 7 ++++++- .../GatewayEndpointStoreTests.swift | 2 +- 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index d960d3c038a..e9ca6c35359 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -480,8 +480,7 @@ final class AppState { remote.removeValue(forKey: "url") remoteChanged = true } - } else { - let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl + } else if let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) { if (remote["url"] as? String) != normalizedUrl { remote["url"] = normalizedUrl remoteChanged = true diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift index 3c7b8abdb69..6d0259300b5 100644 --- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift +++ b/apps/macos/Sources/OpenClaw/GatewayDiscoveryHelpers.swift @@ -42,7 +42,8 @@ enum GatewayDiscoveryHelpers { guard let endpoint = self.serviceEndpoint(serviceHost: serviceHost, servicePort: servicePort) else { return nil } - let scheme = endpoint.port == 443 ? "wss" : "ws" + // Security: for non-loopback hosts, force TLS to avoid plaintext credential/session leakage. + let scheme = self.isLoopbackHost(endpoint.host) ? "ws" : "wss" let portSuffix = endpoint.port == 443 ? "" : ":\(endpoint.port)" return "\(scheme)://\(endpoint.host)\(portSuffix)" } @@ -50,4 +51,16 @@ enum GatewayDiscoveryHelpers { private static func trimmed(_ value: String?) -> String? { value?.trimmingCharacters(in: .whitespacesAndNewlines) } + + private static func isLoopbackHost(_ rawHost: String) -> Bool { + let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !host.isEmpty else { return false } + if host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" { + return true + } + if host.hasPrefix("::ffff:127.") { + return true + } + return host.hasPrefix("127.") + } } diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index b97c5a7a512..c91a82d8130 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -303,7 +303,9 @@ struct GeneralSettings: View { .disabled(self.remoteStatus == .checking || self.state.remoteUrl .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } - Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://).") + Text( + "Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1." + ) .font(.caption) .foregroundStyle(.secondary) .padding(.leading, self.remoteLabelWidth + 10) @@ -546,7 +548,9 @@ extension GeneralSettings { return } guard Self.isValidWsUrl(trimmedUrl) else { - self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://") + self.remoteStatus = .failed( + "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)" + ) return } } else { @@ -603,11 +607,7 @@ extension GeneralSettings { } private static func isValidWsUrl(_ raw: String) -> Bool { - guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } - let scheme = url.scheme?.lowercased() ?? "" - guard scheme == "ws" || scheme == "wss" else { return false } - let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !host.isEmpty + GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil } private static func sshCheckCommand(target: String, identity: String) -> [String]? { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift index 98c3d7b08ea..63bb1fc5742 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift @@ -62,7 +62,12 @@ struct GatewayDiscoveryHelpersTests { let wsGateway = self.makeGateway( serviceHost: "resolved.example.ts.net", servicePort: 18789) - #expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "ws://resolved.example.ts.net:18789") + #expect(GatewayDiscoveryHelpers.directUrl(for: wsGateway) == "wss://resolved.example.ts.net:18789") + + let localGateway = self.makeGateway( + serviceHost: "127.0.0.1", + servicePort: 18789) + #expect(GatewayDiscoveryHelpers.directUrl(for: localGateway) == "ws://127.0.0.1:18789") } @Test func directUrlRejectsTxtOnlyFallback() { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift index 0d42e8d8c83..bb969aeaec9 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift @@ -225,7 +225,7 @@ import Testing } @Test func normalizeGatewayUrlRejectsNonLoopbackWs() { - let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway") + let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway.example:18789") #expect(url == nil) }