fix(ios): keep authenticated Control UI pages bound to the trusted Gateway [AI] (#119906)

* fix(ios): enforce gateway TLS pins in control pages

* test(ios): run control UI trust regressions in CI

* test(ios): avoid nested Testing macros

* fix(ios): preserve control page navigation

* fix(ios): keep authenticated control pages on origin

* fix(ios): canonicalize control page IPv6 hosts

* chore(ios): refresh native i18n inventory

* fix(ios): normalize default TLS challenge ports

* fix(apps): share gateway TLS authority matching

* test(apps): fix authority CI validation

* chore(ci): drop control UI test routing
This commit is contained in:
Pavan Kumar Gondhi
2026-08-12 15:18:19 +05:30
committed by GitHub
parent cb58073a90
commit 5eb18c1387
10 changed files with 283 additions and 54 deletions
+25 -17
View File
@@ -15971,7 +15971,7 @@
},
{
"kind": "ui-modifier",
"line": 30,
"line": 31,
"path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift",
"source": "Dashboard",
"surface": "apple",
@@ -15979,7 +15979,7 @@
},
{
"kind": "ui-call",
"line": 37,
"line": 38,
"path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift",
"source": "Done",
"surface": "apple",
@@ -15987,7 +15987,7 @@
},
{
"kind": "ui-call",
"line": 47,
"line": 48,
"path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift",
"source": "Dashboard needs a connected gateway",
"surface": "apple",
@@ -15995,7 +15995,7 @@
},
{
"kind": "ui-call",
"line": 49,
"line": 50,
"path": "apps/ios/Sources/Chat/SessionDashboardScreen.swift",
"source": "Connect to your gateway to open this session dashboard.",
"surface": "apple",
@@ -27755,7 +27755,7 @@
},
{
"kind": "ui-modifier",
"line": 44,
"line": 45,
"path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift",
"source": "Terminal",
"surface": "apple",
@@ -27763,7 +27763,7 @@
},
{
"kind": "ui-modifier",
"line": 56,
"line": 57,
"path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift",
"source": "Gateway settings",
"surface": "apple",
@@ -27771,7 +27771,7 @@
},
{
"kind": "ui-call",
"line": 70,
"line": 71,
"path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift",
"source": "Terminal needs a connected gateway",
"surface": "apple",
@@ -27779,7 +27779,7 @@
},
{
"kind": "ui-call",
"line": 72,
"line": 73,
"path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift",
"source": "Connect to your gateway to open a shell in the agent workspace.",
"surface": "apple",
@@ -27787,7 +27787,7 @@
},
{
"kind": "ui-call",
"line": 78,
"line": 79,
"path": "apps/ios/Sources/Terminal/TerminalHubScreen.swift",
"source": "Open Gateway Settings",
"surface": "apple",
@@ -28529,14 +28529,6 @@
"surface": "apple",
"id": "native.apple.00887489a998411e"
},
{
"kind": "conditional-branch",
"line": 108,
"path": "apps/ios/Sources/Web/AuthenticatedControlUIWebView.swift",
"source": "[\\(host)]",
"surface": "apple",
"id": "native.apple.944004de80cfc8b7"
},
{
"kind": "plist-string",
"line": 24,
@@ -43065,6 +43057,22 @@
"surface": "apple",
"id": "native.apple.081a07c7306b223e"
},
{
"kind": "conditional-branch",
"line": 711,
"path": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift",
"source": "[\\(self.host)]",
"surface": "apple",
"id": "native.apple.1dba7d27dc80088a"
},
{
"kind": "conditional-branch",
"line": 712,
"path": "apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift",
"source": ":\\(self.port)",
"surface": "apple",
"id": "native.apple.c25ea221748a03ba"
},
{
"kind": "conditional-branch",
"line": 116,
@@ -18,7 +18,8 @@ struct SessionDashboardScreen: View {
authScript: AuthenticatedControlUI.authUserScript(
config: config,
pageURL: url,
storedOperatorToken: storedOperatorToken))
storedOperatorToken: storedOperatorToken),
tls: config?.tls)
.id(AuthenticatedControlUI.webContentIdentity(
config: config,
storedOperatorToken: storedOperatorToken))
@@ -30,7 +30,8 @@ struct TerminalHubScreen: View {
url: url,
authScript: Self.terminalAuthUserScript(
config: config,
storedOperatorToken: storedOperatorToken))
storedOperatorToken: storedOperatorToken),
tls: config?.tls)
// Recreate the web view only when the connection inputs
// change; SwiftUI update passes must not restart live shells.
.id(Self.webContentIdentity(
@@ -93,6 +93,10 @@ enum AuthenticatedControlUI {
static func webContentIdentity(config: GatewayConnectConfig?, storedOperatorToken: String?) -> Int {
var hasher = Hasher()
hasher.combine(config?.url)
hasher.combine(config?.tls?.required)
hasher.combine(config?.tls?.expectedFingerprint)
hasher.combine(config?.tls?.allowTOFU)
hasher.combine(config?.tls?.storeKey)
hasher.combine(config?.token)
hasher.combine(config?.password)
hasher.combine(storedOperatorToken?.trimmingCharacters(in: .whitespacesAndNewlines))
@@ -104,13 +108,7 @@ enum AuthenticatedControlUI {
}
private static func originString(for url: URL) -> String {
guard let scheme = url.scheme, let host = url.host else { return "" }
let hostPart = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
var origin = "\(scheme)://\(hostPart)"
if let port = url.port {
origin += ":\(port)"
}
return origin
GatewayTLSAuthority(url: url)?.serialized ?? ""
}
private static func jsStringLiteral(_ value: String) -> String {
@@ -134,12 +132,89 @@ enum AuthenticatedControlUI {
}
}
@MainActor
final class AuthenticatedControlUIWebViewCoordinator: NSObject, WKNavigationDelegate {
private let expectedOrigin: GatewayTLSAuthority?
private let tls: GatewayTLSParams?
init(url: URL, tls: GatewayTLSParams?) {
self.expectedOrigin = GatewayTLSAuthority(url: url)
self.tls = tls
}
func webView(
_: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void)
{
decisionHandler(self.allowsNavigation(
to: navigationAction.request.url,
isMainFrame: navigationAction.targetFrame?.isMainFrame) ? .allow : .cancel)
}
func webView(
_: WKWebView,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping @MainActor @Sendable (
URLSession.AuthChallengeDisposition,
URLCredential?) -> Void)
{
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let tls
else {
completionHandler(.performDefaultHandling, nil)
return
}
guard self.matchesExpectedAuthority(
host: challenge.protectionSpace.host,
port: challenge.protectionSpace.port)
else {
// Cross-origin main-frame loads are already cancelled by navigation policy.
// Other authorities may belong to embedded content and do not inherit the Gateway pin.
completionHandler(.performDefaultHandling, nil)
return
}
guard let trust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
switch GatewayTLSServerTrust.evaluate(
trust: trust,
host: challenge.protectionSpace.host,
port: challenge.protectionSpace.port,
params: tls)
{
case .accept:
completionHandler(.useCredential, URLCredential(trust: trust))
case .reject:
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
func allowsNavigation(to candidateURL: URL?, isMainFrame: Bool?) -> Bool {
if isMainFrame == false {
return true
}
guard isMainFrame == true, let candidateURL else { return false }
return GatewayTLSAuthority(url: candidateURL) == self.expectedOrigin
}
func matchesExpectedAuthority(host: String, port: Int) -> Bool {
self.expectedOrigin?.matches(host: host, port: port) == true
}
}
/// Ephemeral, script-hardened WKWebView for a self-contained Control UI page.
struct AuthenticatedControlUIWebView: UIViewRepresentable {
let url: URL
let authScript: String?
let tls: GatewayTLSParams?
func makeUIView(context _: Context) -> WKWebView {
func makeCoordinator() -> AuthenticatedControlUIWebViewCoordinator {
AuthenticatedControlUIWebViewCoordinator(url: self.url, tls: self.tls)
}
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.defaultWebpagePreferences.allowsContentJavaScript = true
@@ -152,6 +227,7 @@ struct AuthenticatedControlUIWebView: UIViewRepresentable {
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.isOpaque = true
webView.backgroundColor = .black
webView.allowsLinkPreview = false
@@ -173,7 +249,11 @@ struct AuthenticatedControlUIWebView: UIViewRepresentable {
// Connection changes recreate the view via `.id`; unrelated SwiftUI passes must not reload it.
}
static func dismantleUIView(_ webView: WKWebView, coordinator _: Void) {
static func dismantleUIView(
_ webView: WKWebView,
coordinator _: AuthenticatedControlUIWebViewCoordinator)
{
webView.stopLoading()
webView.navigationDelegate = nil
}
}
+90 -1
View File
@@ -9,13 +9,14 @@ struct TerminalHubScreenTests {
url: URL,
token: String? = nil,
password: String? = nil,
tls: GatewayTLSParams? = nil,
allowStoredDeviceAuth: Bool = true,
deviceAuthGatewayID: String? = nil) -> GatewayConnectConfig
{
GatewayConnectConfig(
url: url,
stableID: "manual|gateway.example.com|443",
tls: nil,
tls: tls,
token: token,
bootstrapToken: nil,
password: password,
@@ -69,6 +70,17 @@ struct TerminalHubScreenTests {
#expect(script?.contains("\"gatewayUrl\":\"wss:\\/\\/gateway.example.com:8443\"") == true)
}
@Test func `auth user script canonicalizes an explicit default port`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:443")),
token: "secret-token")
let script = TerminalHubScreen.terminalAuthUserScript(config: config)
#expect(script?.contains("\"https:\\/\\/gateway.example.com\"") == true)
#expect(script?.contains("\"https:\\/\\/gateway.example.com:443\"") == false)
}
@Test func `auth user script falls back to stored operator token`() throws {
let config = try Self.makeConfig(
url: #require(URL(string: "wss://gateway.example.com:8443")),
@@ -139,6 +151,83 @@ struct TerminalHubScreenTests {
TerminalHubScreen.webContentIdentity(config: config, storedOperatorToken: "token-b"))
}
@Test func `web content identity changes with the accepted TLS pin`() throws {
let url = try #require(URL(string: "wss://gateway.example.com"))
let first = Self.makeConfig(
url: url,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "first",
allowTOFU: false,
storeKey: "gateway"))
let second = Self.makeConfig(
url: url,
tls: GatewayTLSParams(
required: true,
expectedFingerprint: "second",
allowTOFU: false,
storeKey: "gateway"))
#expect(
TerminalHubScreen.webContentIdentity(config: first, storedOperatorToken: nil) !=
TerminalHubScreen.webContentIdentity(config: second, storedOperatorToken: nil))
}
@Test func `authenticated Control UI origin rejects authority changes`() throws {
let controlURL = try #require(URL(string: "https://gateway.example.com/control"))
let defaultPortURL = try #require(URL(string: "https://GATEWAY.example.com:443/chat"))
let alternatePortURL = try #require(URL(string: "https://gateway.example.com:8443/chat"))
let alternateHostURL = try #require(URL(string: "https://replacement.example.com/chat"))
let insecureURL = try #require(URL(string: "http://gateway.example.com/chat"))
let expected = try #require(GatewayTLSAuthority(url: controlURL))
#expect(expected == GatewayTLSAuthority(url: defaultPortURL))
#expect(expected != GatewayTLSAuthority(url: alternatePortURL))
#expect(expected != GatewayTLSAuthority(url: alternateHostURL))
#expect(expected != GatewayTLSAuthority(url: insecureURL))
}
@Test func `authenticated Control UI canonicalizes IPv6 authorities`() throws {
let controlURL = try #require(URL(string: "https://[2001:db8::1]:8443/control"))
let expected = try #require(GatewayTLSAuthority(url: controlURL))
#expect(expected.serialized == "https://[2001:db8::1]:8443")
#expect(expected.matches(host: "2001:DB8::1", port: 8443))
#expect(expected.matches(host: "[2001:db8::1]", port: 8443))
#expect(!expected.matches(host: "2001:db8::2", port: 8443))
#expect(!expected.matches(host: "2001:db8::1", port: 443))
}
@Test func `authenticated Control UI navigation keeps the main frame on its origin`() throws {
let controlURL = try #require(URL(string: "https://gateway.example.com/control"))
let sameOriginURL = try #require(URL(string: "https://gateway.example.com/chat?session=main"))
let alternateHostURL = try #require(URL(string: "https://replacement.example.com/chat"))
let alternatePortURL = try #require(URL(string: "https://gateway.example.com:8443/chat"))
let embeddedURL = try #require(URL(string: "https://discussion.example.com/embed/thread/a/b"))
let unknownFrameURL = try #require(URL(string: "https://gateway.example.com/chat"))
let coordinator = try AuthenticatedControlUIWebViewCoordinator(
url: controlURL,
tls: nil)
#expect(coordinator.allowsNavigation(to: sameOriginURL, isMainFrame: true))
#expect(!coordinator.allowsNavigation(to: alternateHostURL, isMainFrame: true))
#expect(!coordinator.allowsNavigation(to: alternatePortURL, isMainFrame: true))
#expect(coordinator.allowsNavigation(to: embeddedURL, isMainFrame: false))
#expect(!coordinator.allowsNavigation(to: unknownFrameURL, isMainFrame: nil))
}
@Test func `authenticated Control UI TLS authority uses the normalized page authority`() throws {
let controlURL = try #require(URL(string: "https://Gateway.Example.com/control"))
let coordinator = try AuthenticatedControlUIWebViewCoordinator(
url: controlURL,
tls: nil)
#expect(coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 0))
#expect(coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 443))
#expect(!coordinator.matchesExpectedAuthority(host: "gateway.example.com", port: 8443))
#expect(!coordinator.matchesExpectedAuthority(host: "replacement.example.com", port: 443))
}
@Test func `auth user script is omitted without credentials`() throws {
let config = try Self.makeConfig(url: #require(URL(string: "wss://gateway.example.com")), token: " ")
@@ -69,10 +69,7 @@ extension DashboardWindowController {
}
static func isExpectedTLSAuthority(host: String, port: Int, dashboardURL: URL) -> Bool {
let expectedHost = dashboardURL.host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let challengedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let expectedPort = dashboardURL.port ?? (dashboardURL.scheme?.lowercased() == "https" ? 443 : 80)
return expectedHost?.isEmpty == false && challengedHost == expectedHost && port == expectedPort
GatewayTLSAuthority(url: dashboardURL)?.matches(host: host, port: port) == true
}
static func gatewaysRequest(from body: Any) -> DashboardGatewaysRequest? {
@@ -140,10 +140,18 @@ struct DashboardGatewaysBridgeTests {
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
#expect(controller._testTLSParams == params)
#expect(DashboardWindowController.isExpectedTLSAuthority(
host: "gateway.example",
port: 0,
dashboardURL: url))
#expect(DashboardWindowController.isExpectedTLSAuthority(
host: "gateway.example",
port: 443,
dashboardURL: url))
#expect(!DashboardWindowController.isExpectedTLSAuthority(
host: "gateway.example",
port: 8443,
dashboardURL: url))
#expect(!DashboardWindowController.isExpectedTLSAuthority(
host: "other.example",
port: 443,
@@ -740,11 +740,9 @@ private final class ChatInlineWidgetNavigationDelegate: NSObject, WKNavigationDe
}
private func matchesExpectedProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Bool {
guard let expectedHost = self.resource.url.host,
protectionSpace.host.caseInsensitiveCompare(expectedHost) == .orderedSame
else { return false }
let expectedPort = self.resource.url.port ?? (self.resource.url.scheme?.lowercased() == "https" ? 443 : 80)
return protectionSpace.port == expectedPort
GatewayTLSAuthority(url: self.resource.url)?.matches(
host: protectionSpace.host,
port: protectionSpace.port) == true
}
}
@@ -683,25 +683,49 @@ public protocol GatewayTLSRouteMetadataProviding: AnyObject {
var effectiveTLSFingerprintSHA256: String? { get }
}
struct GatewayTLSAuthority: Equatable, Sendable {
let host: String
let port: Int
public struct GatewayTLSAuthority: Equatable, Sendable {
public let scheme: String
public let host: String
public let port: Int
private let defaultPort: Int
init?(url: URL) {
guard let host = Self.normalizedHost(url.host) else { return nil }
public init?(url: URL) {
guard let scheme = url.scheme?.lowercased(),
let defaultPort = Self.defaultPort(for: scheme),
let host = Self.normalizedHost(url.host)
else { return nil }
self.scheme = scheme
self.host = host
self.port = url.port ?? (url.scheme?.lowercased() == "wss" ? 443 : 80)
self.port = url.port ?? defaultPort
self.defaultPort = defaultPort
}
init?(host: String, port: Int) {
guard let host = Self.normalizedHost(host) else { return nil }
self.host = host
self.port = port
public func matches(host: String, port: Int) -> Bool {
// URLProtectionSpace uses 0 for the protocol's default port. Normalize it here so
// every pinned Apple transport reaches the same authority decision.
let challengePort = port == 0 ? self.defaultPort : port
return Self.normalizedHost(host) == self.host && challengePort == self.port
}
public var serialized: String {
let hostPart = self.host.contains(":") ? "[\(self.host)]" : self.host
return "\(self.scheme)://\(hostPart)" + (self.port == self.defaultPort ? "" : ":\(self.port)")
}
private static func defaultPort(for scheme: String) -> Int? {
switch scheme {
case "http", "ws": 80
case "https", "wss": 443
default: nil
}
}
private static func normalizedHost(_ host: String?) -> String? {
let value = host?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
return value.isEmpty ? nil : value
guard !value.isEmpty else { return nil }
return value.hasPrefix("[") && value.hasSuffix("]")
? String(value.dropFirst().dropLast())
: value
}
}
@@ -871,9 +895,8 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
let host = challenge.protectionSpace.host
let port = challenge.protectionSpace.port
let expected = self.currentEnforcedFingerprint()
let challengedAuthority = GatewayTLSAuthority(host: host, port: port)
guard let expectedAuthority = self.currentExpectedAuthority(),
challengedAuthority == expectedAuthority
expectedAuthority.matches(host: host, port: port)
else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: .authorityMismatch,
@@ -153,10 +153,17 @@ struct GatewayTLSPinningTests {
@Test func `TLS authority includes normalized host and effective port`() throws {
let url = try #require(URL(string: "wss://Gateway.Example.com/path"))
let route = try #require(GatewayTLSAuthority(url: url))
let explicitPortURL = try #require(URL(string: "wss://gateway.example.com:8443/path"))
let explicitPort = try #require(GatewayTLSAuthority(url: explicitPortURL))
#expect(route == GatewayTLSAuthority(host: "gateway.example.com", port: 443))
#expect(route != GatewayTLSAuthority(host: "redirect.example.com", port: 443))
#expect(route != GatewayTLSAuthority(host: "gateway.example.com", port: 8443))
#expect(route.host == "gateway.example.com")
#expect(route.port == 443)
#expect(route.matches(host: "gateway.example.com", port: 0))
#expect(route.matches(host: "gateway.example.com", port: 443))
#expect(!route.matches(host: "redirect.example.com", port: 443))
#expect(!route.matches(host: "gateway.example.com", port: 8443))
#expect(!explicitPort.matches(host: "gateway.example.com", port: 0))
#expect(explicitPort.matches(host: "gateway.example.com", port: 8443))
}
@Test func `matching explicit pin overrides system trust`() {
@@ -200,6 +207,23 @@ struct GatewayTLSPinningTests {
params: mismatch) == .reject)
}
@Test func `server trust evaluator rejects a different system-trusted certificate after pinning`() throws {
let trust = try gatewayTLSTestTrust(systemTrusted: true)
let pinnedFingerprint = SHA256.hash(data: Data("previous certificate".utf8))
.map { String(format: "%02x", $0) }.joined()
let params = GatewayTLSParams(
required: true,
expectedFingerprint: pinnedFingerprint,
allowTOFU: false,
storeKey: "profile:pinned")
#expect(GatewayTLSServerTrust.evaluate(
trust: trust,
host: "gateway.example",
port: 443,
params: params) == .reject)
}
@Test func `server trust evaluator claims trusted first use`() throws {
try self.withFakeKeychain { _ in
let trust = try gatewayTLSTestTrust(systemTrusted: true)