fix(security): harden discovery routing and TLS pins

This commit is contained in:
Peter Steinberger
2026-02-14 17:17:46 +01:00
parent 61d59a8028
commit d583782ee3
17 changed files with 503 additions and 110 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
- Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058) - Security: fix Chutes manual OAuth login state validation (thanks @aether-ai-agent). (#16058)
- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. - Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.

View File

@@ -405,8 +405,11 @@ class NodeRuntime(context: Context) {
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
gateways.collect { list -> gateways.collect { list ->
if (list.isNotEmpty()) { if (list.isNotEmpty()) {
// Persist the last discovered gateway (best-effort UX parity with iOS). // Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
prefs.setLastDiscoveredStableId(list.last().stableId) // UX parity with iOS: only set once when unset.
if (lastDiscoveredStableId.value.trim().isEmpty()) {
prefs.setLastDiscoveredStableId(list.first().stableId)
}
} }
if (didAutoConnect) return@collect if (didAutoConnect) return@collect
@@ -425,6 +428,11 @@ class NodeRuntime(context: Context) {
val targetStableId = lastDiscoveredStableId.value.trim() val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true didAutoConnect = true
connect(target) connect(target)
} }

View File

@@ -26,6 +26,59 @@ class ConnectionManager(
private val hasRecordAudioPermission: () -> Boolean, private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean, private val manualTls: () -> Boolean,
) { ) {
companion object {
internal fun resolveTlsParamsForEndpoint(
endpoint: GatewayEndpoint,
storedFingerprint: String?,
manualTlsEnabled: Boolean,
): GatewayTlsParams? {
val stableId = endpoint.stableId
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
val isManual = stableId.startsWith("manual|")
if (isManual) {
if (!manualTlsEnabled) return null
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = stableId,
)
}
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
allowTOFU = true,
stableId = stableId,
)
}
// Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint.
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = stableId,
)
}
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
if (hinted) {
// TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative.
return GatewayTlsParams(
required = true,
expectedFingerprint = null,
allowTOFU = true,
stableId = stableId,
)
}
return null
}
}
fun buildInvokeCommands(): List<String> = fun buildInvokeCommands(): List<String> =
buildList { buildList {
add(OpenClawCanvasCommand.Present.rawValue) add(OpenClawCanvasCommand.Present.rawValue)
@@ -130,37 +183,6 @@ class ConnectionManager(
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? { fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
val manual = endpoint.stableId.startsWith("manual|")
if (manual) {
if (!manualTls()) return null
return GatewayTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (hinted) {
return GatewayTlsParams(
required = true,
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
allowTOFU = stored == null,
stableId = endpoint.stableId,
)
}
if (!stored.isNullOrBlank()) {
return GatewayTlsParams(
required = true,
expectedFingerprint = stored,
allowTOFU = false,
stableId = endpoint.stableId,
)
}
return null
} }
} }

View File

@@ -0,0 +1,77 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.GatewayEndpoint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ConnectionManagerTest {
@Test
fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.0.2",
port = 18789,
tlsEnabled = true,
tlsFingerprintSha256 = "attacker",
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = "legit",
manualTlsEnabled = false,
)
assertEquals("legit", params?.expectedFingerprint)
assertEquals(false, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() {
val endpoint =
GatewayEndpoint(
stableId = "_openclaw-gw._tcp.|local.|Test",
name = "Test",
host = "10.0.0.2",
port = 18789,
tlsEnabled = true,
tlsFingerprintSha256 = "attacker",
)
val params =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(params?.expectedFingerprint)
assertEquals(true, params?.allowTOFU)
}
@Test
fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() {
val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
val off =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = false,
)
assertNull(off)
val on =
ConnectionManager.resolveTlsParamsForEndpoint(
endpoint,
storedFingerprint = null,
manualTlsEnabled = true,
)
assertNull(on?.expectedFingerprint)
assertEquals(true, on?.allowTOFU)
}
}

View File

@@ -23,6 +23,7 @@ final class GatewayConnectionController {
private let discovery = GatewayDiscoveryModel() private let discovery = GatewayDiscoveryModel()
private weak var appModel: NodeAppModel? private weak var appModel: NodeAppModel?
private var didAutoConnect = false private var didAutoConnect = false
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
init(appModel: NodeAppModel, startDiscovery: Bool = true) { init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.appModel = appModel self.appModel = appModel
@@ -57,21 +58,30 @@ final class GatewayConnectionController {
} }
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
await self.connectDiscoveredGateway(gateway, allowTOFU: true)
}
private func connectDiscoveredGateway(
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) async
{
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
guard let host = self.resolveGatewayHost(gateway) else { return }
let port = gateway.gatewayPort ?? 18789 // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway) guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return }
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
guard let url = self.buildGatewayURL( guard let url = self.buildGatewayURL(
host: host, host: target.host,
port: port, port: target.port,
useTLS: tlsParams?.required == true) useTLS: tlsParams?.required == true)
else { return } else { return }
GatewaySettingsStore.saveLastGatewayConnection( GatewaySettingsStore.saveLastGatewayConnection(
host: host, host: target.host,
port: port, port: target.port,
useTLS: tlsParams?.required == true, useTLS: tlsParams?.required == true,
stableID: gateway.stableID) stableID: gateway.stableID)
self.didAutoConnect = true self.didAutoConnect = true
@@ -254,36 +264,26 @@ final class GatewayConnectionController {
self.gateways.contains(where: { $0.stableID == id }) self.gateways.contains(where: { $0.stableID == id })
}) { }) {
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return } guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
guard let host = self.resolveGatewayHost(target) else { return } // Security: autoconnect only to previously trusted gateways (stored TLS pin).
let port = target.gatewayPort ?? 18789 guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( Task { [weak self] in
url: url, guard let self else { return }
gatewayStableID: target.stableID, await self.connectDiscoveredGateway(target, allowTOFU: false)
tls: tlsParams, }
token: token,
password: password)
return return
} }
if self.gateways.count == 1, let gateway = self.gateways.first { if self.gateways.count == 1, let gateway = self.gateways.first {
guard let host = self.resolveGatewayHost(gateway) else { return } // Security: autoconnect only to previously trusted gateways (stored TLS pin).
let port = gateway.gatewayPort ?? 18789 guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
else { return }
self.didAutoConnect = true self.didAutoConnect = true
self.startAutoConnect( Task { [weak self] in
url: url, guard let self else { return }
gatewayStableID: gateway.stableID, await self.connectDiscoveredGateway(gateway, allowTOFU: false)
tls: tlsParams, }
token: token,
password: password)
return return
} }
} }
@@ -339,15 +339,27 @@ final class GatewayConnectionController {
} }
} }
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? { private func resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
let stableID = gateway.stableID let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil { // Never let unauthenticated discovery (TXT) override a stored pin.
if let stored {
return GatewayTLSParams( return GatewayTLSParams(
required: true, required: true,
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored, expectedFingerprint: stored,
allowTOFU: stored == nil, allowTOFU: false,
storeKey: stableID)
}
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
return GatewayTLSParams(
required: true,
expectedFingerprint: nil,
allowTOFU: allowTOFU,
storeKey: stableID) storeKey: stableID)
} }
@@ -371,14 +383,19 @@ final class GatewayConnectionController {
return nil return nil
} }
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty { guard case let .service(name, type, domain, _) = endpoint else { return nil }
return tailnet let key = "\(domain)|\(type)|\(name)"
return await withCheckedContinuation { continuation in
let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
Task { @MainActor in
self?.pendingServiceResolvers[key] = nil
continuation.resume(returning: result)
}
}
self.pendingServiceResolvers[key] = resolver
resolver.start()
} }
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
return lanHost
}
return nil
} }
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? { private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
@@ -662,5 +679,16 @@ extension GatewayConnectionController {
func _test_triggerAutoConnect() { func _test_triggerAutoConnect() {
self.maybeAutoConnect() self.maybeAutoConnect()
} }
func _test_didAutoConnect() -> Bool {
self.didAutoConnect
}
func _test_resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
}
} }
#endif #endif

View File

@@ -0,0 +1,55 @@
import Foundation
// NetService-based resolver for Bonjour services.
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService
private let completion: ((host: String, port: Int)?) -> Void
private var didFinish = false
init(
name: String,
type: String,
domain: String,
completion: @escaping ((host: String, port: Int)?) -> Void)
{
self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion
super.init()
self.service.delegate = self
}
func start(timeout: TimeInterval = 2.0) {
self.service.schedule(in: .main, forMode: .common)
self.service.resolve(withTimeout: timeout)
}
func netServiceDidResolveAddress(_ sender: NetService) {
let host = Self.normalizeHost(sender.hostName)
let port = sender.port
guard let host, !host.isEmpty, port > 0 else {
self.finish(result: nil)
return
}
self.finish(result: (host: host, port: port))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: nil)
}
private func finish(result: ((host: String, port: Int))?) {
guard !self.didFinish else { return }
self.didFinish = true
self.service.stop()
self.service.remove(from: .main, forMode: .common)
self.completion(result)
}
private static func normalizeHost(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return nil }
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
}
}

View File

@@ -0,0 +1,105 @@
import Foundation
import Network
import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
private func clearTLSFingerprint(stableID: String) {
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
suite.removeObject(forKey: "gateway.tls.\(stableID)")
}
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: "22",
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == "11")
#expect(params?.allowTOFU == false)
}
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: "22",
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == nil)
#expect(params?.allowTOFU == true)
}
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.autoconnect")
defaults.set(false, forKey: "gateway.manual.enabled")
defaults.removeObject(forKey: "gateway.last.host")
defaults.removeObject(forKey: "gateway.last.port")
defaults.removeObject(forKey: "gateway.last.tls")
defaults.removeObject(forKey: "gateway.last.stableID")
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: nil,
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
#expect(controller._test_didAutoConnect() == false)
}
}

View File

@@ -15,19 +15,29 @@ enum GatewayDiscoveryHelpers {
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl( self.directGatewayUrl(
tailnetDns: gateway.tailnetDns, serviceHost: gateway.serviceHost,
servicePort: gateway.servicePort,
lanHost: gateway.lanHost, lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort) gatewayPort: gateway.gatewayPort)
} }
static func directGatewayUrl( static func directGatewayUrl(
tailnetDns: String?, serviceHost: String?,
servicePort: Int?,
lanHost: String?, lanHost: String?,
gatewayPort: Int?) -> String? gatewayPort: Int?) -> String?
{ {
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) { // Security: do not route using unauthenticated TXT hints (tailnetDns/lanHost/gatewayPort).
return "wss://\(tailnetDns)" // Prefer the resolved service endpoint (SRV + A/AAAA).
if let host = self.trimmed(serviceHost), !host.isEmpty,
let port = servicePort, port > 0
{
let scheme = port == 443 ? "wss" : "ws"
let portSuffix = port == 443 ? "" : ":\(port)"
return "\(scheme)://\(host)\(portSuffix)"
} }
// Legacy fallback (best-effort): keep existing behavior when we couldn't resolve SRV.
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil } guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789 let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)" return "ws://\(lanHost):\(port)"

View File

@@ -683,7 +683,9 @@ extension GeneralSettings {
host: host, host: host,
port: gateway.sshPort) port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? "" self.state.remoteCliPath = gateway.cliPath ?? ""
OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) OpenClawConfigFile.setRemoteGatewayUrl(
host: gateway.serviceHost ?? host,
port: gateway.servicePort ?? gateway.gatewayPort)
} }
} }
} }

View File

@@ -35,7 +35,9 @@ extension OnboardingView {
user: user, user: user,
host: host, host: host,
port: gateway.sshPort) port: gateway.sshPort)
OpenClawConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort) OpenClawConfigFile.setRemoteGatewayUrl(
host: gateway.serviceHost ?? host,
port: gateway.servicePort ?? gateway.gatewayPort)
} }
self.state.remoteCliPath = gateway.cliPath ?? "" self.state.remoteCliPath = gateway.cliPath ?? ""

View File

@@ -20,6 +20,9 @@ public final class GatewayDiscoveryModel {
public struct DiscoveredGateway: Identifiable, Equatable, Sendable { public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
public var id: String { self.stableID } public var id: String { self.stableID }
public var displayName: String public var displayName: String
// Resolved service endpoint (SRV + A/AAAA). Used for routing; do not trust TXT for routing.
public var serviceHost: String?
public var servicePort: Int?
public var lanHost: String? public var lanHost: String?
public var tailnetDns: String? public var tailnetDns: String?
public var sshPort: Int public var sshPort: Int
@@ -31,6 +34,8 @@ public final class GatewayDiscoveryModel {
public init( public init(
displayName: String, displayName: String,
serviceHost: String? = nil,
servicePort: Int? = nil,
lanHost: String? = nil, lanHost: String? = nil,
tailnetDns: String? = nil, tailnetDns: String? = nil,
sshPort: Int, sshPort: Int,
@@ -41,6 +46,8 @@ public final class GatewayDiscoveryModel {
isLocal: Bool) isLocal: Bool)
{ {
self.displayName = displayName self.displayName = displayName
self.serviceHost = serviceHost
self.servicePort = servicePort
self.lanHost = lanHost self.lanHost = lanHost
self.tailnetDns = tailnetDns self.tailnetDns = tailnetDns
self.sshPort = sshPort self.sshPort = sshPort
@@ -62,8 +69,8 @@ public final class GatewayDiscoveryModel {
private var localIdentity: LocalIdentity private var localIdentity: LocalIdentity
private let localDisplayName: String? private let localDisplayName: String?
private let filterLocalGateways: Bool private let filterLocalGateways: Bool
private var resolvedTXTByID: [String: [String: String]] = [:] private var resolvedServiceByID: [String: ResolvedGatewayService] = [:]
private var pendingTXTResolvers: [String: GatewayTXTResolver] = [:] private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var wideAreaFallbackTask: Task<Void, Never>? private var wideAreaFallbackTask: Task<Void, Never>?
private var wideAreaFallbackGateways: [DiscoveredGateway] = [] private var wideAreaFallbackGateways: [DiscoveredGateway] = []
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery") private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
@@ -133,9 +140,9 @@ public final class GatewayDiscoveryModel {
self.resultsByDomain = [:] self.resultsByDomain = [:]
self.gatewaysByDomain = [:] self.gatewaysByDomain = [:]
self.statesByDomain = [:] self.statesByDomain = [:]
self.resolvedTXTByID = [:] self.resolvedServiceByID = [:]
self.pendingTXTResolvers.values.forEach { $0.cancel() } self.pendingServiceResolvers.values.forEach { $0.cancel() }
self.pendingTXTResolvers = [:] self.pendingServiceResolvers = [:]
self.wideAreaFallbackTask?.cancel() self.wideAreaFallbackTask?.cancel()
self.wideAreaFallbackTask = nil self.wideAreaFallbackTask = nil
self.wideAreaFallbackGateways = [] self.wideAreaFallbackGateways = []
@@ -154,6 +161,8 @@ public final class GatewayDiscoveryModel {
local: self.localIdentity) local: self.localIdentity)
return DiscoveredGateway( return DiscoveredGateway(
displayName: beacon.displayName, displayName: beacon.displayName,
serviceHost: beacon.host,
servicePort: beacon.port,
lanHost: beacon.lanHost, lanHost: beacon.lanHost,
tailnetDns: beacon.tailnetDns, tailnetDns: beacon.tailnetDns,
sshPort: beacon.sshPort ?? 22, sshPort: beacon.sshPort ?? 22,
@@ -195,7 +204,8 @@ public final class GatewayDiscoveryModel {
let decodedName = BonjourEscapes.decode(name) let decodedName = BonjourEscapes.decode(name)
let stableID = GatewayEndpointID.stableID(result.endpoint) let stableID = GatewayEndpointID.stableID(result.endpoint)
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:] let resolved = self.resolvedServiceByID[stableID]
let resolvedTXT = resolved?.txt ?? [:]
let txt = Self.txtDictionary(from: result).merging( let txt = Self.txtDictionary(from: result).merging(
resolvedTXT, resolvedTXT,
uniquingKeysWith: { _, new in new }) uniquingKeysWith: { _, new in new })
@@ -208,8 +218,10 @@ public final class GatewayDiscoveryModel {
let parsedTXT = Self.parseGatewayTXT(txt) let parsedTXT = Self.parseGatewayTXT(txt)
if parsedTXT.lanHost == nil || parsedTXT.tailnetDns == nil { // Always attempt NetService resolution for the endpoint (host/port and TXT).
self.ensureTXTResolution( // TXT is unauthenticated; do not use it for routing.
if resolved == nil {
self.ensureServiceResolution(
stableID: stableID, stableID: stableID,
serviceName: name, serviceName: name,
type: type, type: type,
@@ -224,6 +236,8 @@ public final class GatewayDiscoveryModel {
local: self.localIdentity) local: self.localIdentity)
return DiscoveredGateway( return DiscoveredGateway(
displayName: prettyName, displayName: prettyName,
serviceHost: resolved?.host,
servicePort: resolved?.port,
lanHost: parsedTXT.lanHost, lanHost: parsedTXT.lanHost,
tailnetDns: parsedTXT.tailnetDns, tailnetDns: parsedTXT.tailnetDns,
sshPort: parsedTXT.sshPort, sshPort: parsedTXT.sshPort,
@@ -421,16 +435,16 @@ public final class GatewayDiscoveryModel {
return target return target
} }
private func ensureTXTResolution( private func ensureServiceResolution(
stableID: String, stableID: String,
serviceName: String, serviceName: String,
type: String, type: String,
domain: String) domain: String)
{ {
guard self.resolvedTXTByID[stableID] == nil else { return } guard self.resolvedServiceByID[stableID] == nil else { return }
guard self.pendingTXTResolvers[stableID] == nil else { return } guard self.pendingServiceResolvers[stableID] == nil else { return }
let resolver = GatewayTXTResolver( let resolver = GatewayServiceResolver(
name: serviceName, name: serviceName,
type: type, type: type,
domain: domain, domain: domain,
@@ -438,10 +452,10 @@ public final class GatewayDiscoveryModel {
{ [weak self] result in { [weak self] result in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.pendingTXTResolvers[stableID] = nil self.pendingServiceResolvers[stableID] = nil
switch result { switch result {
case let .success(txt): case let .success(resolved):
self.resolvedTXTByID[stableID] = txt self.resolvedServiceByID[stableID] = resolved
self.updateGatewaysForAllDomains() self.updateGatewaysForAllDomains()
self.recomputeGateways() self.recomputeGateways()
case .failure: case .failure:
@@ -450,7 +464,7 @@ public final class GatewayDiscoveryModel {
} }
} }
self.pendingTXTResolvers[stableID] = resolver self.pendingServiceResolvers[stableID] = resolver
resolver.start() resolver.start()
} }
@@ -607,9 +621,15 @@ public final class GatewayDiscoveryModel {
} }
} }
final class GatewayTXTResolver: NSObject, NetServiceDelegate { struct ResolvedGatewayService: Equatable, Sendable {
var txt: [String: String]
var host: String?
var port: Int?
}
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
private let service: NetService private let service: NetService
private let completion: (Result<[String: String], Error>) -> Void private let completion: (Result<ResolvedGatewayService, Error>) -> Void
private let logger: Logger private let logger: Logger
private var didFinish = false private var didFinish = false
@@ -618,7 +638,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
type: String, type: String,
domain: String, domain: String,
logger: Logger, logger: Logger,
completion: @escaping (Result<[String: String], Error>) -> Void) completion: @escaping (Result<ResolvedGatewayService, Error>) -> Void)
{ {
self.service = NetService(domain: domain, type: type, name: name) self.service = NetService(domain: domain, type: type, name: name)
self.completion = completion self.completion = completion
@@ -633,24 +653,27 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
} }
func cancel() { func cancel() {
self.finish(result: .failure(GatewayTXTResolverError.cancelled)) self.finish(result: .failure(GatewayServiceResolverError.cancelled))
} }
func netServiceDidResolveAddress(_ sender: NetService) { func netServiceDidResolveAddress(_ sender: NetService) {
let txt = Self.decodeTXT(sender.txtRecordData()) let txt = Self.decodeTXT(sender.txtRecordData())
let host = Self.normalizeHost(sender.hostName)
let port = sender.port > 0 ? sender.port : nil
if !txt.isEmpty { if !txt.isEmpty {
let payload = self.formatTXT(txt) let payload = self.formatTXT(txt)
self.logger.debug( self.logger.debug(
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)") "discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
} }
self.finish(result: .success(txt)) let resolved = ResolvedGatewayService(txt: txt, host: host, port: port)
self.finish(result: .success(resolved))
} }
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
self.finish(result: .failure(GatewayTXTResolverError.resolveFailed(errorDict))) self.finish(result: .failure(GatewayServiceResolverError.resolveFailed(errorDict)))
} }
private func finish(result: Result<[String: String], Error>) { private func finish(result: Result<ResolvedGatewayService, Error>) {
guard !self.didFinish else { return } guard !self.didFinish else { return }
self.didFinish = true self.didFinish = true
self.service.stop() self.service.stop()
@@ -671,6 +694,12 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
return out return out
} }
private static func normalizeHost(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return nil }
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
}
private func formatTXT(_ txt: [String: String]) -> String { private func formatTXT(_ txt: [String: String]) -> String {
txt.sorted(by: { $0.key < $1.key }) txt.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\($0.value)" } .map { "\($0.key)=\($0.value)" }
@@ -678,7 +707,7 @@ final class GatewayTXTResolver: NSObject, NetServiceDelegate {
} }
} }
enum GatewayTXTResolverError: Error { enum GatewayServiceResolverError: Error {
case cancelled case cancelled
case resolveFailed([String: NSNumber]) case resolveFailed([String: NSNumber])
} }

View File

@@ -100,6 +100,12 @@ The Gateway advertises small nonsecret hints to make UI flows convenient:
- `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint) - `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint)
- `tailnetDns=<magicdns>` (optional hint when Tailnet is available) - `tailnetDns=<magicdns>` (optional hint when Tailnet is available)
Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must not treat TXT as authoritative routing.
- Clients should route using the resolved service endpoint (SRV + A/AAAA). Treat `lanHost`, `tailnetDns`, `gatewayPort`, and `gatewayTlsSha256` as hints only.
- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin.
## Debugging on macOS ## Debugging on macOS
Useful builtin tools: Useful builtin tools:

View File

@@ -35,7 +35,9 @@ Legacy `bridge.*` config keys are no longer part of the config schema.
- Legacy default listener port was `18790` (current builds do not start a TCP bridge). - Legacy default listener port was `18790` (current builds do not start a TCP bridge).
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
`bridgeTlsSha256` so nodes can pin the certificate. `bridgeTlsSha256` as a non-secret hint. Note that Bonjour/mDNS TXT records are
unauthenticated; clients must not treat the advertised fingerprint as an
authoritative pin without explicit user intent or other out-of-band verification.
## Handshake + pairing ## Handshake + pairing

View File

@@ -68,6 +68,12 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
- `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint or binary) - `cliPath=<path>` (optional; absolute path to a runnable `openclaw` entrypoint or binary)
- `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available) - `tailnetDns=<magicdns>` (optional hint; auto-detected when Tailscale is available)
Security notes:
- Bonjour/mDNS TXT records are **unauthenticated**. Clients must treat TXT values as UX hints only.
- Routing (host/port) should prefer the **resolved service endpoint** (SRV + A/AAAA) over TXT-provided `lanHost`, `tailnetDns`, or `gatewayPort`.
- TLS pinning must never allow an advertised `gatewayTlsSha256` to override a previously stored pin. For first-time connections, require explicit user intent (TOFU or other out-of-band verification).
Disable/override: Disable/override:
- `OPENCLAW_DISABLE_BONJOUR=1` disables advertising. - `OPENCLAW_DISABLE_BONJOUR=1` disables advertising.

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { GatewayBonjourBeacon } from "../../infra/bonjour-discovery.js";
import { pickBeaconHost, pickGatewayPort } from "./discover.js";
describe("gateway discover routing helpers", () => {
it("prefers resolved service host over TXT hints", () => {
const beacon: GatewayBonjourBeacon = {
instanceName: "Test",
host: "10.0.0.2",
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
};
expect(pickBeaconHost(beacon)).toBe("10.0.0.2");
});
it("prefers resolved service port over TXT gatewayPort", () => {
const beacon: GatewayBonjourBeacon = {
instanceName: "Test",
host: "10.0.0.2",
port: 18789,
gatewayPort: 12345,
};
expect(pickGatewayPort(beacon)).toBe(18789);
});
it("falls back to TXT host/port when resolve data is missing", () => {
const beacon: GatewayBonjourBeacon = {
instanceName: "Test",
lanHost: "test-host.local",
gatewayPort: 18789,
};
expect(pickBeaconHost(beacon)).toBe("test-host.local");
expect(pickGatewayPort(beacon)).toBe(18789);
});
});

View File

@@ -30,12 +30,15 @@ export function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number
} }
export function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null { export function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
const host = beacon.tailnetDns || beacon.lanHost || beacon.host; // Security: TXT records are unauthenticated. Prefer the resolved service endpoint (SRV/A/AAAA)
// over TXT-provided routing hints.
const host = beacon.host || beacon.tailnetDns || beacon.lanHost;
return host?.trim() ? host.trim() : null; return host?.trim() ? host.trim() : null;
} }
export function pickGatewayPort(beacon: GatewayBonjourBeacon): number { export function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
const port = beacon.gatewayPort ?? 18789; // Security: TXT records are unauthenticated. Prefer the resolved service port over TXT gatewayPort.
const port = beacon.port ?? beacon.gatewayPort ?? 18789;
return port > 0 ? port : 18789; return port > 0 ? port : 18789;
} }

View File

@@ -8,12 +8,14 @@ import { detectBinary } from "./onboard-helpers.js";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
function pickHost(beacon: GatewayBonjourBeacon): string | undefined { function pickHost(beacon: GatewayBonjourBeacon): string | undefined {
return beacon.tailnetDns || beacon.lanHost || beacon.host; // Security: TXT is unauthenticated. Prefer the resolved service endpoint host.
return beacon.host || beacon.tailnetDns || beacon.lanHost;
} }
function buildLabel(beacon: GatewayBonjourBeacon): string { function buildLabel(beacon: GatewayBonjourBeacon): string {
const host = pickHost(beacon); const host = pickHost(beacon);
const port = beacon.gatewayPort ?? beacon.port ?? 18789; // Security: Prefer the resolved service endpoint port.
const port = beacon.port ?? beacon.gatewayPort ?? 18789;
const title = beacon.displayName ?? beacon.instanceName; const title = beacon.displayName ?? beacon.instanceName;
const hint = host ? `${host}:${port}` : "host unknown"; const hint = host ? `${host}:${port}` : "host unknown";
return `${title} (${hint})`; return `${title} (${hint})`;
@@ -80,7 +82,7 @@ export async function promptRemoteGatewayConfig(
if (selectedBeacon) { if (selectedBeacon) {
const host = pickHost(selectedBeacon); const host = pickHost(selectedBeacon);
const port = selectedBeacon.gatewayPort ?? 18789; const port = selectedBeacon.port ?? selectedBeacon.gatewayPort ?? 18789;
if (host) { if (host) {
const mode = await prompter.select({ const mode = await prompter.select({
message: "Connection method", message: "Connection method",