Security/macos: enforce wss for non-loopback direct gateway

This commit is contained in:
Brian Mendonca
2026-02-20 18:41:11 -07:00
committed by Peter Steinberger
parent 8942ac04a8
commit 617e38cec0
5 changed files with 29 additions and 12 deletions

View File

@@ -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

View File

@@ -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.")
}
}

View File

@@ -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://<magicdns>).")
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]? {

View File

@@ -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() {

View File

@@ -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)
}