Files
openclaw-openclaw/apps/ios/Tests/PushRelayHeaderIsolationTests.swift
Peter Steinberger 7853aa35a4 feat(ios): per-gateway custom headers for gateways behind authenticating proxies (#100768)
* feat(ios): support per-gateway custom headers for proxied gateway connections

Per-gateway custom request headers (Cloudflare Access-style service tokens)
for gateways behind authenticating reverse proxies. Headers are credentials:
Keychain-stored per gateway stableID, masked in settings UI, injected on the
WebSocket upgrade at connect time via a provider so edits apply on the next
reconnect without re-pairing. Reserved handshake headers and control chars
are sanitized out. TLS pinning and pairing flows unchanged. Push relay and
other non-gateway destinations proven header-free by regression test.

Related: #100698

* chore(ios): sync native i18n inventory for custom header strings

* fix(ios): harden gateway custom headers

* chore(ios): resync native i18n inventory

* fix(ios): validate custom header names
2026-07-06 17:17:07 +01:00

89 lines
3.3 KiB
Swift

import Foundation
import Testing
@testable import OpenClaw
/// Gateway custom headers are proxy credentials scoped to one gateway. They ride only the
/// gateway WebSocket upgrade; the push relay is a different trust domain and must never
/// observe them, even while they sit in the store.
private final class CapturingURLProtocol: URLProtocol {
private static let lock = NSLock()
private nonisolated(unsafe) static var requests: [URLRequest] = []
static func drain() -> [URLRequest] {
self.lock.lock()
defer { self.lock.unlock() }
let drained = self.requests
self.requests = []
return drained
}
private static func record(_ request: URLRequest) {
self.lock.lock()
self.requests.append(request)
self.lock.unlock()
}
override class func canInit(with request: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
Self.record(self.request)
guard let url = self.request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 503,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"])
else {
self.client?.urlProtocol(self, didFailWithError: URLError(.badURL))
return
}
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: Data("{}".utf8))
self.client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
struct PushRelayHeaderIsolationTests {
@Test func `push relay requests never carry gateway custom headers`() async throws {
let gatewayID = "manual|proxied.example.com|443|\(UUID().uuidString)"
defer { GatewaySettingsStore.saveGatewayCustomHeaders([:], gatewayStableID: gatewayID) }
#expect(GatewaySettingsStore.saveGatewayCustomHeaders(
["CF-Access-Client-Id": "client-id", "CF-Access-Client-Secret": "client-secret"],
gatewayStableID: gatewayID))
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [CapturingURLProtocol.self]
let client = try PushRelayClient(
baseURL: #require(URL(string: "https://relay.example.invalid")),
session: URLSession(configuration: configuration))
let input = PushRelayRegistrationInput(
installationId: "test-install",
bundleId: "ai.openclaw.tests",
appVersion: "1.0",
environment: .sandbox,
relayProfile: .deviceSandbox,
proofPolicy: .appleDevelopment,
distribution: .local,
apnsTokenHex: "00",
gatewayIdentity: PushRelayGatewayIdentity(deviceId: "device", publicKey: "key"))
// Registration fails at the stubbed challenge; the captured request is what matters.
_ = try? await client.register(input)
let requests = CapturingURLProtocol.drain()
#expect(!requests.isEmpty)
for request in requests {
#expect(request.value(forHTTPHeaderField: "CF-Access-Client-Id") == nil)
#expect(request.value(forHTTPHeaderField: "CF-Access-Client-Secret") == nil)
}
}
}