diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyCredential.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyCredential.swift new file mode 100644 index 000000000000..d86046fefe83 --- /dev/null +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyCredential.swift @@ -0,0 +1,58 @@ +/// Username and password the loopback remote-workspace browser proxy requires. +/// +/// The proxy listens on `127.0.0.1`, which every local account can reach, so +/// each tunnel start mints a fresh random credential and hands it only to the +/// embedded browser. The value stays in process memory: descriptions are +/// redacted, and it is never written to logs, argv or socket payloads. +public struct BrowserProxyCredential: Equatable, Sendable, CustomStringConvertible, CustomDebugStringConvertible { + /// SOCKS5 username (RFC 1929) and HTTP Basic user. + public let username: String + /// SOCKS5 password (RFC 1929) and HTTP Basic password. + public let password: String + + /// Creates a credential from explicit values. + public init(username: String, password: String) { + self.username = username + self.password = password + } + + /// Mints a credential whose password carries 256 random bits. + public static func random() -> BrowserProxyCredential { + var generator = SystemRandomNumberGenerator() + let hexDigits = Array("0123456789abcdef".utf8) + var passwordBytes: [UInt8] = [] + passwordBytes.reserveCapacity(64) + for _ in 0..<32 { + let byte = UInt8.random(in: .min ... .max, using: &generator) + passwordBytes.append(hexDigits[Int(byte >> 4)]) + passwordBytes.append(hexDigits[Int(byte & 0x0F)]) + } + return BrowserProxyCredential( + username: "cmux", + password: String(decoding: passwordBytes, as: UTF8.self) + ) + } + + /// Returns whether the offered username and password match, comparing + /// the bytes in constant time for equal lengths. + public func matches(username offeredUsername: [UInt8], password offeredPassword: [UInt8]) -> Bool { + let usernameMatches = Self.constantTimeEqual(Array(username.utf8), offeredUsername) + let passwordMatches = Self.constantTimeEqual(Array(password.utf8), offeredPassword) + return usernameMatches && passwordMatches + } + + /// Redacted so interpolating the credential never exposes the password. + public var description: String { "BrowserProxyCredential(redacted)" } + + /// Redacted like ``description``. + public var debugDescription: String { description } + + private static func constantTimeEqual(_ lhs: [UInt8], _ rhs: [UInt8]) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for index in lhs.indices { + difference |= lhs[index] ^ rhs[index] + } + return difference == 0 + } +} diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyEndpoint.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyEndpoint.swift index 26eba4fd2a14..6b55c6940a38 100644 --- a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyEndpoint.swift +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/BrowserProxyEndpoint.swift @@ -1,14 +1,27 @@ /// A loopback HTTP/SOCKS proxy endpoint the embedded browser routes through to /// reach services on a remote workspace host. -public struct BrowserProxyEndpoint: Equatable, Sendable { +/// +/// Descriptions are redacted to host and port so the credential never +/// reaches logs or status payloads through interpolation. +public struct BrowserProxyEndpoint: Equatable, Sendable, CustomStringConvertible, CustomDebugStringConvertible { /// Proxy host, always a loopback address in practice. public let host: String /// Proxy TCP port. public let port: Int + /// Credential the proxy requires on every SOCKS5 and HTTP CONNECT + /// handshake; minted per tunnel start. + public let credential: BrowserProxyCredential - /// Creates an endpoint value; mirrors the original memberwise initializer. - public init(host: String, port: Int) { + /// Creates an endpoint value. + public init(host: String, port: Int, credential: BrowserProxyCredential) { self.host = host self.port = port + self.credential = credential } + + /// Host and port only; the credential is never included. + public var description: String { "BrowserProxyEndpoint(\(host):\(port))" } + + /// Redacted like ``description``. + public var debugDescription: String { description } } diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/ManagedProxySessionDelegate.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/ManagedProxySessionDelegate.swift new file mode 100644 index 000000000000..4299fdb6e764 --- /dev/null +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Remote/ManagedProxySessionDelegate.swift @@ -0,0 +1,27 @@ +public import Foundation + +/// Session delegate for requests routed through a cmux proxy that already +/// carries its credential. +/// +/// Without a delegate, a proxy 407 reaches the system, which asks the user +/// for a proxy password. For a cmux proxy that only means a stale credential +/// or another process on the loopback port, so the request fails instead. +/// Other challenges keep the default handling. +public final class ManagedProxySessionDelegate: NSObject, URLSessionTaskDelegate, Sendable { + public override init() { + super.init() + } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + (Self.disposition(for: challenge.protectionSpace), nil) + } + + /// Cancels proxy challenges; defers everything else to the system. + public static func disposition(for protectionSpace: URLProtectionSpace) -> URLSession.AuthChallengeDisposition { + protectionSpace.isProxy() ? .cancelAuthenticationChallenge : .performDefaultHandling + } +} diff --git a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/BrowserProxyCredentialTests.swift b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/BrowserProxyCredentialTests.swift new file mode 100644 index 000000000000..1d6f89f1aa7f --- /dev/null +++ b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/BrowserProxyCredentialTests.swift @@ -0,0 +1,44 @@ +import CmuxCore +import Foundation +import Testing + +@Suite("BrowserProxyCredential") +struct BrowserProxyCredentialTests { + @Test("random credentials carry a 64 hex digit password and differ per mint") + func randomCredentialsAreUnique() { + let first = BrowserProxyCredential.random() + let second = BrowserProxyCredential.random() + + #expect(first.password.utf8.count == 64) + #expect(first.password.allSatisfy { $0.isHexDigit }) + #expect(first.password != second.password) + } + + @Test("matches only the exact username and password bytes") + func matchesExactBytes() { + let credential = BrowserProxyCredential(username: "cmux", password: "secret") + + #expect(credential.matches(username: Array("cmux".utf8), password: Array("secret".utf8))) + #expect(!credential.matches(username: Array("cmux".utf8), password: Array("secreT".utf8))) + #expect(!credential.matches(username: Array("cmux".utf8), password: Array("secret!".utf8))) + #expect(!credential.matches(username: Array("cmux".utf8), password: [])) + #expect(!credential.matches(username: Array("other".utf8), password: Array("secret".utf8))) + } + + @Test("descriptions never include the password") + func descriptionsAreRedacted() { + let credential = BrowserProxyCredential.random() + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 9876, credential: credential) + + for text in [ + "\(credential)", + String(reflecting: credential), + "\(endpoint)", + String(reflecting: endpoint), + String(reflecting: Optional(endpoint)), + ] { + #expect(!text.contains(credential.password)) + } + #expect("\(endpoint)" == "BrowserProxyEndpoint(127.0.0.1:9876)") + } +} diff --git a/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/ManagedProxySessionDelegateTests.swift b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/ManagedProxySessionDelegateTests.swift new file mode 100644 index 000000000000..18abdb3c9aed --- /dev/null +++ b/Packages/macOS/CmuxCore/Tests/CmuxCoreTests/ManagedProxySessionDelegateTests.swift @@ -0,0 +1,64 @@ +import CmuxCore +import Foundation +import Testing + +@Suite("ManagedProxySessionDelegate") +struct ManagedProxySessionDelegateTests { + @Test("proxy challenges are cancelled instead of reaching the system prompt", arguments: [ + NSURLAuthenticationMethodHTTPBasic, + NSURLAuthenticationMethodDefault, + ]) + func cancelsProxyChallenges(method: String) async { + let protectionSpace = URLProtectionSpace( + proxyHost: "127.0.0.1", + port: 9876, + type: NSURLProtectionSpaceHTTPSProxy, + realm: "cmux", + authenticationMethod: method + ) + + let (disposition, credential) = await Self.answer(protectionSpace) + + #expect(disposition == .cancelAuthenticationChallenge) + #expect(credential == nil) + } + + @Test("server challenges keep the default handling") + func leavesServerChallengesAlone() async { + let protectionSpace = URLProtectionSpace( + host: "example.test", + port: 443, + protocol: NSURLProtectionSpaceHTTPS, + realm: "site", + authenticationMethod: NSURLAuthenticationMethodHTTPBasic + ) + + let (disposition, credential) = await Self.answer(protectionSpace) + + #expect(disposition == .performDefaultHandling) + #expect(credential == nil) + } + + private static func answer( + _ protectionSpace: URLProtectionSpace + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let challenge = URLAuthenticationChallenge( + protectionSpace: protectionSpace, + proposedCredential: nil, + previousFailureCount: 0, + failureResponse: nil, + error: nil, + sender: UnusedChallengeSender() + ) + let session = URLSession(configuration: .ephemeral) + defer { session.invalidateAndCancel() } + let task = session.dataTask(with: URL(string: "http://example.test/")!) + return await ManagedProxySessionDelegate().urlSession(session, task: task, didReceive: challenge) + } +} + +private final class UnusedChallengeSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + func cancel(_ challenge: URLAuthenticationChallenge) {} +} diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/IntentionalCleanupTestTunnelProvider.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/IntentionalCleanupTestTunnelProvider.swift index d6843fcbbf3f..0a573b8c4cec 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/IntentionalCleanupTestTunnelProvider.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/IntentionalCleanupTestTunnelProvider.swift @@ -14,6 +14,7 @@ final class IntentionalCleanupTestTunnelProvider: RemoteProxyTunnelProviding, @u configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, onFatalError: @escaping @Sendable (String) -> Void ) -> any RemoteProxyTunneling { lock.withLock { _makeCount += 1 } diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NeverReadyProxyTunnelProvider.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NeverReadyProxyTunnelProvider.swift index a42de7007452..ee4bc585349e 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NeverReadyProxyTunnelProvider.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/NeverReadyProxyTunnelProvider.swift @@ -7,6 +7,7 @@ struct NeverReadyProxyTunnelProvider: RemoteProxyTunnelProviding { configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, onFatalError: @escaping @Sendable (String) -> Void ) -> any RemoteProxyTunneling { NeverReadyProxyTunnel() diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift index 39e520fa0160..8578ec9fad5e 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePTYIntentionalCleanupTests.swift @@ -90,7 +90,7 @@ struct RemotePTYIntentionalCleanupTests { private static func markReady(_ coordinator: RemoteSessionCoordinator, lease: RemoteProxyLease) { coordinator.queue.sync { coordinator.proxyLease = lease - coordinator.proxyEndpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 42_424) + coordinator.proxyEndpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 42_424, credential: .random()) coordinator.daemonReady = true } } diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift index 069551532bee..9493e6f160d3 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemotePortScanGatingTests.swift @@ -22,7 +22,7 @@ struct RemotePortScanGatingTests { let runner = SpyProcessRunner() let host = RecordingRemoteSessionHost() let coordinator = Self.makeCoordinator(runner: runner, host: host, terminalStartupCommand: "true") - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 49152) + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 49152, credential: .random()) coordinator.queue.sync { coordinator.proxyEndpoint = endpoint diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift index 81dd7d0ddd83..cc178d6d4a0b 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteReconnectPolicyTests.swift @@ -245,7 +245,7 @@ struct RemoteReconnectPolicyTests { coordinator.queue.sync {} provider.tunnel.stop() } - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 42_424) + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 42_424, credential: .random()) coordinator.queue.sync { coordinator.proxyLeaseGeneration = 2 diff --git a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionReadinessParkingTests.swift b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionReadinessParkingTests.swift index afa191b060cd..03861aeec798 100644 --- a/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionReadinessParkingTests.swift +++ b/Packages/macOS/CmuxRemoteSession/Tests/CmuxRemoteSessionTests/RemoteSessionReadinessParkingTests.swift @@ -303,7 +303,7 @@ struct RemoteSessionReadinessParkingTests { ) { _ in } coordinator.queue.sync { coordinator.proxyLease = lease - coordinator.proxyEndpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 42_424) + coordinator.proxyEndpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: 42_424, credential: .random()) coordinator.daemonReady = true } diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteDaemonProxyTunnelProvider.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteDaemonProxyTunnelProvider.swift index 0e97a4cc2537..12527e65e779 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteDaemonProxyTunnelProvider.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteDaemonProxyTunnelProvider.swift @@ -34,12 +34,14 @@ public struct RemoteDaemonProxyTunnelProvider: RemoteProxyTunnelProviding { configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, onFatalError: @escaping @Sendable (String) -> Void ) -> any RemoteProxyTunneling { RemoteDaemonProxyTunnel( configuration: configuration, remotePath: remotePath, localPort: localPort, + credential: credential, strings: strings, ptyBridgeStrings: ptyBridgeStrings, clock: clock, diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyBroker.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyBroker.swift index 6a3eb7ba6b90..27f35e44dbfe 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyBroker.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyBroker.swift @@ -438,10 +438,13 @@ public final class RemoteProxyBroker: @unchecked Sendable { do { let tunnelGeneration = UUID() + // Fresh per tunnel start; only the embedded browser receives it. + let credential = BrowserProxyCredential.random() let tunnel = tunnelProvider.makeTunnel( configuration: entry.configuration, remotePath: entry.remotePath, - localPort: localPort + localPort: localPort, + credential: credential ) { [weak self] detail in guard let self else { return } self.queue.async { @@ -459,7 +462,7 @@ public final class RemoteProxyBroker: @unchecked Sendable { entry.tunnel = tunnel entry.tunnelGeneration = tunnelGeneration entry.ptyLifecycleSnapshot = nil - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort, credential: credential) entry.endpoint = endpoint entry.restartRetryCount = 0 notifyLocked(entry, update: .ready(endpoint)) diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyTunnelProviding.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyTunnelProviding.swift index 69b2f128b0aa..5aeed70ac390 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyTunnelProviding.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Broker/RemoteProxyTunnelProviding.swift @@ -14,6 +14,8 @@ public protocol RemoteProxyTunnelProviding: Sendable { /// - Parameters: /// - remotePath: Resolved remote path of the daemon binary. /// - localPort: Loopback port the tunnel's proxy listener binds to. + /// - credential: Credential the tunnel's proxy listener requires from + /// every client. /// - onFatalError: Invoked once when the started tunnel fails /// irrecoverably (it has already stopped itself); may fire on any /// queue. @@ -21,6 +23,7 @@ public protocol RemoteProxyTunnelProviding: Sendable { configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, onFatalError: @escaping @Sendable (String) -> Void ) -> any RemoteProxyTunneling } diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxySession.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxySession.swift index 4c02eca54ebc..3999913da547 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxySession.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxySession.swift @@ -9,6 +9,11 @@ import Network /// stream, then shuttles bytes both ways (rewriting loopback-alias HTTP /// headers when the target is the alias host). /// +/// The listener is reachable by every local account, so both handshakes must +/// present the tunnel's ``BrowserProxyCredential`` before any daemon stream +/// opens: SOCKS5 through username/password authentication (RFC 1929), HTTP +/// CONNECT through `Proxy-Authorization: Basic`. +/// /// Isolation design (faithful lift of the legacy nested `ProxySession`): all /// mutable state is confined to the tunnel's serial `queue`. Mutators are the /// `NWConnection` receive/state callbacks (started on `queue`), the RPC @@ -30,6 +35,7 @@ final class RemoteDaemonProxySession: @unchecked Sendable { private enum SocksStage { case greeting + case authentication case request } @@ -43,11 +49,15 @@ final class RemoteDaemonProxySession: @unchecked Sendable { let id = UUID() private let connection: NWConnection + private let credential: BrowserProxyCredential private let rpcClient: any RemoteDaemonTunnelRPCClient private let queue: DispatchQueue private let onClose: (UUID) -> Void private var isClosed = false + /// Set once a final response is queued; later handshake bytes are ignored + /// so a refused client cannot retry on the same connection. + private var isClosingAfterResponse = false private var protocolKind: HandshakeProtocol = .undecided private var socksStage: SocksStage = .greeting private var handshakeBuffer = Data() @@ -60,11 +70,13 @@ final class RemoteDaemonProxySession: @unchecked Sendable { init( connection: NWConnection, + credential: BrowserProxyCredential, rpcClient: any RemoteDaemonTunnelRPCClient, queue: DispatchQueue, onClose: @escaping (UUID) -> Void ) { self.connection = connection + self.credential = credential self.rpcClient = rpcClient self.queue = queue self.onClose = onClose @@ -131,7 +143,7 @@ final class RemoteDaemonProxySession: @unchecked Sendable { } private func processHandshakeBuffer() { - guard !isClosed else { return } + guard !isClosed, !isClosingAfterResponse else { return } while streamID == nil { switch protocolKind { case .undecided: @@ -159,13 +171,35 @@ final class RemoteDaemonProxySession: @unchecked Sendable { let methods = [UInt8](handshakeBuffer[2..= 2 else { return false } + let usernameLength = Int(bytes[1]) + guard bytes.count >= 3 + usernameLength else { return false } + let passwordLength = Int(bytes[2 + usernameLength]) + let total = 3 + usernameLength + passwordLength + guard bytes.count >= total else { return false } + + let username = Array(bytes[2..<(2 + usernameLength)]) + let password = Array(bytes[(3 + usernameLength)..= 2, parts[0].uppercased() == "CONNECT" else { sendAndClose(Self.httpResponse(status: "400 Bad Request")) @@ -297,6 +340,28 @@ final class RemoteDaemonProxySession: @unchecked Sendable { return false } + /// Returns whether the first `Proxy-Authorization` header carries this + /// tunnel's credential as HTTP Basic. + private func hasValidProxyAuthorization(headerLines: ArraySlice) -> Bool { + let prefix = "proxy-authorization:" + guard let line = headerLines.first(where: { $0.lowercased().hasPrefix(prefix) }) else { + return false + } + let value = line.dropFirst(prefix.count).trimmingCharacters(in: .whitespaces) + let parts = value.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard parts.count == 2, + parts[0].lowercased() == "basic", + let decoded = Data(base64Encoded: parts[1].trimmingCharacters(in: .whitespaces)) else { + return false + } + let bytes = [UInt8](decoded) + guard let colon = bytes.firstIndex(of: UInt8(ascii: ":")) else { return false } + return credential.matches( + username: Array(bytes[.. Data { + private static func httpResponse( + status: String, + closeAfterResponse: Bool = true, + extraHeaders: [String] = [] + ) -> Data { var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + for header in extraHeaders { + text += "\(header)\r\n" + } if closeAfterResponse { text += "Connection: close\r\n" } diff --git a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift index b2be4c060cbf..8ec62c950c6b 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/Tunnel/RemoteDaemonProxyTunnel.swift @@ -26,6 +26,7 @@ public final class RemoteDaemonProxyTunnel: @unchecked Sendable { private let configuration: WorkspaceRemoteConfiguration private let remotePath: String private let localPort: Int + private let credential: BrowserProxyCredential private let strings: RemoteDaemonStrings let ptyBridgeStrings: any RemotePTYBridgeStrings let clock: any RemoteProxyRetryClock @@ -44,6 +45,8 @@ public final class RemoteDaemonProxyTunnel: @unchecked Sendable { /// - Parameters: /// - remotePath: Resolved remote path of the daemon binary. /// - localPort: Loopback port to bind the proxy listener to. + /// - credential: Credential every proxy client must present before a + /// daemon stream opens. /// - strings: App-resolved daemon error strings, passed through to the /// RPC client (localization stays app-side). /// - ptyBridgeStrings: App-resolved PTY attach error strings, passed @@ -56,6 +59,7 @@ public final class RemoteDaemonProxyTunnel: @unchecked Sendable { configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, strings: RemoteDaemonStrings, ptyBridgeStrings: any RemotePTYBridgeStrings, clock: any RemoteProxyRetryClock = SystemRemoteProxyRetryClock(), @@ -64,6 +68,7 @@ public final class RemoteDaemonProxyTunnel: @unchecked Sendable { self.configuration = configuration self.remotePath = remotePath self.localPort = localPort + self.credential = credential self.strings = strings self.ptyBridgeStrings = ptyBridgeStrings self.clock = clock @@ -222,6 +227,7 @@ public final class RemoteDaemonProxyTunnel: @unchecked Sendable { let session = RemoteDaemonProxySession( connection: connection, + credential: credential, rpcClient: rpcClient, queue: queue ) { [weak self] id in diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BlockingCloseTunnelProvider.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BlockingCloseTunnelProvider.swift index 79eb04d23f32..e76b8374b044 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BlockingCloseTunnelProvider.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BlockingCloseTunnelProvider.swift @@ -13,6 +13,7 @@ final class BlockingCloseTunnelProvider: RemoteProxyTunnelProviding, @unchecked configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, onFatalError: @escaping @Sendable (String) -> Void ) -> any RemoteProxyTunneling { tunnel diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BridgeTestClient.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BridgeTestClient.swift index ad81ba49a999..fd98c5543a82 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BridgeTestClient.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/BridgeTestClient.swift @@ -2,7 +2,7 @@ import Foundation import Network @testable import CmuxRemoteWorkspace -/// Loopback TCP client helper for talking to a bridge endpoint. +/// Loopback TCP client helper for talking to a bridge or proxy endpoint. final class BridgeTestClient: @unchecked Sendable { private let connection: NWConnection private let queue = DispatchQueue(label: "bridge-test-client") @@ -10,10 +10,14 @@ final class BridgeTestClient: @unchecked Sendable { private var received = Data() private var closed = false - init(endpoint: RemotePTYBridgeServer.Endpoint) { + convenience init(endpoint: RemotePTYBridgeServer.Endpoint) { + self.init(host: endpoint.host, port: endpoint.port) + } + + init(host: String, port: Int) { connection = NWConnection( - host: NWEndpoint.Host(endpoint.host), - port: NWEndpoint.Port(rawValue: UInt16(endpoint.port))!, + host: NWEndpoint.Host(host), + port: NWEndpoint.Port(rawValue: UInt16(port))!, using: .tcp ) connection.start(queue: queue) @@ -70,6 +74,13 @@ final class BridgeTestClient: @unchecked Sendable { return false } + /// Every byte received so far. + var receivedData: Data { + lock.lock() + defer { lock.unlock() } + return received + } + var isClosed: Bool { lock.lock() defer { lock.unlock() } diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/ProxyStreamTestRPCClient.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/ProxyStreamTestRPCClient.swift new file mode 100644 index 000000000000..22cd892c0fb8 --- /dev/null +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/ProxyStreamTestRPCClient.swift @@ -0,0 +1,99 @@ +import CmuxRemoteDaemon +import Foundation +@testable import CmuxRemoteWorkspace + +/// Daemon stream fake for proxy session tests: records every opened target +/// and answers stream writes by echoing them, or with one canned reply and +/// EOF when `cannedReply` is set. +final class ProxyStreamTestRPCClient: RemoteDaemonTunnelRPCClient, @unchecked Sendable { + private struct Attachment: @unchecked Sendable { + let queue: DispatchQueue + let onEvent: (RemoteDaemonStreamEvent) -> Void + } + + private let lock = NSLock() + private let cannedReply: Data? + private var _openedTargets: [String] = [] + private var attachments: [String: Attachment] = [:] + private var repliedStreamIDs: Set = [] + + init(cannedReply: Data? = nil) { + self.cannedReply = cannedReply + } + + /// `host:port` of every stream the proxy asked the daemon to open. + var openedTargets: [String] { lock.withLock { _openedTargets } } + + func openStream(host: String, port: Int, timeoutMs: Int) throws -> String { + lock.withLock { + _openedTargets.append("\(host):\(port)") + return "stream-\(_openedTargets.count)" + } + } + + func attachStream( + streamID: String, + queue: DispatchQueue, + onEvent: @escaping (RemoteDaemonStreamEvent) -> Void + ) throws { + lock.withLock { + attachments[streamID] = Attachment(queue: queue, onEvent: onEvent) + } + } + + func writeStream(streamID: String, data: Data) throws { + let delivery: (Attachment, RemoteDaemonStreamEvent)? = lock.withLock { + guard let attachment = attachments[streamID] else { return nil } + guard let cannedReply else { return (attachment, .data(data)) } + guard repliedStreamIDs.insert(streamID).inserted else { return nil } + return (attachment, .eof(cannedReply)) + } + guard let (attachment, event) = delivery else { return } + attachment.queue.async { attachment.onEvent(event) } + } + + func closeStream(streamID: String) { + lock.withLock { _ = attachments.removeValue(forKey: streamID) } + } + + func stop() {} + + func listPTY() throws -> [[String: Any]] { [] } + + func closePTY(sessionID: String, timeout: TimeInterval) throws {} + + func resizePTY( + sessionID: String, + attachmentID: String, + attachmentToken: String, + cols: Int, + rows: Int + ) throws {} + + func detachPTYChecked(sessionID: String, attachmentID: String, attachmentToken: String) throws {} + + func attachBridgePTY( + sessionID: String, + attachmentID: String, + cols: Int, + rows: Int, + command: String?, + requireExisting: Bool, + inputSeqAck: Bool, + queue: DispatchQueue, + onEvent: @escaping (RemotePTYBridgeEvent) -> Void + ) throws -> RemotePTYBridgeAttachment { + throw NSError(domain: "test.remote.proxy", code: 2) + } + + func writePTY( + sessionID: String, + attachmentID: String, + attachmentToken: String, + data: Data, + seq: UInt64?, + completion: @escaping ((any Error)?) -> Void + ) { completion(nil) } + + func detachPTY(sessionID: String, attachmentID: String, attachmentToken: String) {} +} diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxySessionAuthenticationTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxySessionAuthenticationTests.swift new file mode 100644 index 000000000000..82fe0e843a26 --- /dev/null +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxySessionAuthenticationTests.swift @@ -0,0 +1,307 @@ +import CmuxCore +import Foundation +import Network +import Testing +@testable import CmuxRemoteWorkspace + +/// Loopback listener that runs one ``RemoteDaemonProxySession`` per accepted +/// connection, the way ``RemoteDaemonProxyTunnel`` does, against a fake +/// daemon. State is confined to `queue`, like the tunnel. +final class ProxySessionTestHarness: @unchecked Sendable { + let rpcClient: ProxyStreamTestRPCClient + let credential: BrowserProxyCredential + private let queue = DispatchQueue(label: "proxy-session-test-harness") + private let listener: NWListener + private var sessions: [UUID: RemoteDaemonProxySession] = [:] + + private init(listener: NWListener, credential: BrowserProxyCredential, rpcClient: ProxyStreamTestRPCClient) { + self.listener = listener + self.credential = credential + self.rpcClient = rpcClient + } + + /// Starts a harness on an ephemeral `127.0.0.1` port. + static func start( + credential: BrowserProxyCredential = .random(), + rpcClient: ProxyStreamTestRPCClient = ProxyStreamTestRPCClient() + ) throws -> ProxySessionTestHarness { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + let harness = ProxySessionTestHarness( + listener: try NWListener(using: parameters), + credential: credential, + rpcClient: rpcClient + ) + let ready = DispatchSemaphore(value: 0) + harness.listener.stateUpdateHandler = { state in + if case .ready = state { ready.signal() } + } + harness.listener.newConnectionHandler = { [weak harness] connection in + guard let harness else { + connection.cancel() + return + } + harness.queue.async { harness.acceptLocked(connection) } + } + harness.listener.start(queue: harness.queue) + guard ready.wait(timeout: .now() + 5) == .success, harness.listener.port != nil else { + harness.stop() + throw NSError(domain: "test.remote.proxy", code: 1) + } + return harness + } + + var port: Int { Int(listener.port?.rawValue ?? 0) } + + var endpoint: NWEndpoint { + .hostPort(host: "127.0.0.1", port: NWEndpoint.Port(rawValue: UInt16(port))!) + } + + func makeClient() -> BridgeTestClient { + BridgeTestClient(host: "127.0.0.1", port: port) + } + + func stop() { + listener.cancel() + queue.sync { + let sessions = Array(self.sessions.values) + self.sessions.removeAll() + sessions.forEach { $0.stop() } + } + } + + private func acceptLocked(_ connection: NWConnection) { + let session = RemoteDaemonProxySession( + connection: connection, + credential: credential, + rpcClient: rpcClient, + queue: queue, + onClose: { [weak self] id in + self?.sessions.removeValue(forKey: id) + } + ) + sessions[session.id] = session + session.start() + } +} + +@Suite("Remote daemon proxy session authentication", .serialized) +struct RemoteDaemonProxySessionAuthenticationTests { + private static let socksSuccessReply = Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + + // MARK: SOCKS5 + + @Test func socksGreetingWithoutUsernamePasswordIsRefusedBeforeAnyStreamOpens() throws { + let harness = try ProxySessionTestHarness.start() + defer { harness.stop() } + let client = harness.makeClient() + defer { client.cancel() } + + client.send(Data([0x05, 0x01, 0x00]) + Self.socksConnectRequest(host: "example.test", port: 80)) + + #expect(client.waitForReceived { _, closed in closed }) + #expect(client.receivedData == Data([0x05, 0xFF])) + #expect(harness.rpcClient.openedTargets.isEmpty) + } + + @Test func socksWithWrongPasswordIsRefusedBeforeAnyStreamOpens() throws { + let harness = try ProxySessionTestHarness.start() + defer { harness.stop() } + let client = harness.makeClient() + defer { client.cancel() } + + client.send( + Data([0x05, 0x01, 0x02]) + + Self.socksAuthentication(username: harness.credential.username, password: "wrong") + + Self.socksConnectRequest(host: "example.test", port: 80) + ) + + #expect(client.waitForReceived { _, closed in closed }) + #expect(client.receivedData == Data([0x05, 0x02, 0x01, 0x01])) + #expect(harness.rpcClient.openedTargets.isEmpty) + } + + @Test func socksWithCredentialOpensStreamAndRelaysBytes() throws { + let harness = try ProxySessionTestHarness.start() + defer { harness.stop() } + let client = harness.makeClient() + defer { client.cancel() } + + client.send(Data([0x05, 0x01, 0x02])) + #expect(client.waitForReceived { data, _ in data.count >= 2 }) + #expect(client.receivedData == Data([0x05, 0x02])) + + client.send(Self.socksAuthentication( + username: harness.credential.username, + password: harness.credential.password + )) + #expect(client.waitForReceived { data, _ in data.count >= 4 }) + #expect(client.receivedData == Data([0x05, 0x02, 0x01, 0x00])) + + client.send(Self.socksConnectRequest(host: "example.test", port: 80)) + let handshakeReply = Data([0x05, 0x02, 0x01, 0x00]) + Self.socksSuccessReply + #expect(client.waitForReceived { data, _ in data.count >= handshakeReply.count }) + #expect(client.receivedData == handshakeReply) + + client.send(Data("ping".utf8)) + #expect(client.waitForReceived { data, _ in data == handshakeReply + Data("ping".utf8) }) + #expect(harness.rpcClient.openedTargets == ["example.test:80"]) + #expect(!client.isClosed) + } + + // MARK: HTTP CONNECT + + @Test func connectWithoutProxyAuthorizationIsRefusedBeforeAnyStreamOpens() throws { + let harness = try ProxySessionTestHarness.start() + defer { harness.stop() } + let client = harness.makeClient() + defer { client.cancel() } + + client.send(Self.connectRequest(authority: "example.test:443", proxyAuthorization: nil)) + + #expect(client.waitForReceived { _, closed in closed }) + let response = String(decoding: client.receivedData, as: UTF8.self) + #expect(response.hasPrefix("HTTP/1.1 407 ")) + #expect(response.contains("\r\nProxy-Authenticate: Basic realm=\"cmux\"\r\n")) + #expect(harness.rpcClient.openedTargets.isEmpty) + } + + @Test func connectWithWrongProxyAuthorizationIsRefusedBeforeAnyStreamOpens() throws { + let harness = try ProxySessionTestHarness.start() + defer { harness.stop() } + let client = harness.makeClient() + defer { client.cancel() } + + client.send(Self.connectRequest( + authority: "example.test:443", + proxyAuthorization: Self.basicAuthorization(username: harness.credential.username, password: "wrong") + )) + + #expect(client.waitForReceived { _, closed in closed }) + #expect(String(decoding: client.receivedData, as: UTF8.self).hasPrefix("HTTP/1.1 407 ")) + #expect(harness.rpcClient.openedTargets.isEmpty) + } + + @Test func connectWithProxyAuthorizationOpensStreamAndRelaysBytes() throws { + let harness = try ProxySessionTestHarness.start() + defer { harness.stop() } + let client = harness.makeClient() + defer { client.cancel() } + + client.send(Self.connectRequest( + authority: "example.test:443", + proxyAuthorization: Self.basicAuthorization( + username: harness.credential.username, + password: harness.credential.password + ) + )) + let marker = Data("\r\n\r\n".utf8) + #expect(client.waitForReceived { data, _ in data.range(of: marker) != nil }) + let established = client.receivedData + #expect(String(decoding: established, as: UTF8.self).hasPrefix("HTTP/1.1 200 Connection Established\r\n")) + + client.send(Data("ping".utf8)) + #expect(client.waitForReceived { data, _ in data == established + Data("ping".utf8) }) + #expect(harness.rpcClient.openedTargets == ["example.test:443"]) + #expect(!client.isClosed) + } + + // MARK: Network.framework proxy client (the stack WKWebView uses) + + enum ProxyKind: String, CaseIterable, Sendable { + case socks5 + case httpConnect + } + + @Test(arguments: ProxyKind.allCases) + func proxyConfigurationWithCredentialReachesTarget(_ kind: ProxyKind) async throws { + let harness = try ProxySessionTestHarness.start( + rpcClient: ProxyStreamTestRPCClient(cannedReply: Self.cannedHTTPResponse) + ) + defer { harness.stop() } + + let (data, response) = try await Self.fetch(through: harness, kind: kind, applyCredential: true) + + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(String(decoding: data, as: UTF8.self) == "ok") + #expect(harness.rpcClient.openedTargets == ["example.test:80"]) + } + + @Test(arguments: ProxyKind.allCases) + func proxyConfigurationWithoutCredentialIsRefused(_ kind: ProxyKind) async throws { + let harness = try ProxySessionTestHarness.start( + rpcClient: ProxyStreamTestRPCClient(cannedReply: Self.cannedHTTPResponse) + ) + defer { harness.stop() } + + let statusCode: Int? + do { + let (_, response) = try await Self.fetch(through: harness, kind: kind, applyCredential: false) + statusCode = (response as? HTTPURLResponse)?.statusCode + } catch { + statusCode = nil + } + + #expect(statusCode != 200) + #expect(harness.rpcClient.openedTargets.isEmpty) + } + + // MARK: Helpers + + private static let cannedHTTPResponse = Data( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".utf8 + ) + + private static func fetch( + through harness: ProxySessionTestHarness, + kind: ProxyKind, + applyCredential: Bool + ) async throws -> (Data, URLResponse) { + let proxy = switch kind { + case .socks5: ProxyConfiguration(socksv5Proxy: harness.endpoint) + case .httpConnect: ProxyConfiguration(httpCONNECTProxy: harness.endpoint) + } + if applyCredential { + proxy.applyCredential(username: harness.credential.username, password: harness.credential.password) + } + let configuration = URLSessionConfiguration.ephemeral + configuration.proxyConfigurations = [proxy] + configuration.timeoutIntervalForRequest = 5 + configuration.timeoutIntervalForResource = 10 + // Without the delegate, the refused CONNECT case asks the user for a + // proxy password in a system dialog. + let session = URLSession( + configuration: configuration, + delegate: ManagedProxySessionDelegate(), + delegateQueue: nil + ) + defer { session.invalidateAndCancel() } + return try await session.data(from: URL(string: "http://example.test/")!) + } + + private static func socksAuthentication(username: String, password: String) -> Data { + let usernameBytes = Array(username.utf8) + let passwordBytes = Array(password.utf8) + return Data([0x01, UInt8(usernameBytes.count)] + usernameBytes + [UInt8(passwordBytes.count)] + passwordBytes) + } + + private static func socksConnectRequest(host: String, port: UInt16) -> Data { + let hostBytes = Array(host.utf8) + return Data( + [0x05, 0x01, 0x00, 0x03, UInt8(hostBytes.count)] + hostBytes + [UInt8(port >> 8), UInt8(port & 0xFF)] + ) + } + + private static func basicAuthorization(username: String, password: String) -> String { + "Basic " + Data("\(username):\(password)".utf8).base64EncodedString() + } + + private static func connectRequest(authority: String, proxyAuthorization: String?) -> Data { + var text = "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\n" + if let proxyAuthorization { + text += "Proxy-Authorization: \(proxyAuthorization)\r\n" + } + text += "\r\n" + return Data(text.utf8) + } +} diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxyTunnelPTYBridgeTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxyTunnelPTYBridgeTests.swift index 9edc4159a94d..7ec9cfc02e11 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxyTunnelPTYBridgeTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteDaemonProxyTunnelPTYBridgeTests.swift @@ -434,6 +434,7 @@ struct RemoteDaemonProxyTunnelPTYBridgeTests { ), remotePath: "/remote/cmuxd", localPort: 42_424, + credential: .random(), strings: .init( missingPersistentPTYCapability: "", missingRequiredFunctionality: "", diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteProxyBrokerTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteProxyBrokerTests.swift index d6eacead981a..00c877887488 100644 --- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteProxyBrokerTests.swift +++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteProxyBrokerTests.swift @@ -13,6 +13,7 @@ final class FakeProxyTunnel: RemoteProxyTunneling, @unchecked Sendable { let remotePath: String let localPort: Int + let credential: BrowserProxyCredential private let startError: NSError? let lock = NSLock() private var _startCount = 0 @@ -21,9 +22,10 @@ final class FakeProxyTunnel: RemoteProxyTunneling, @unchecked Sendable { var ptyLifecycleRegistry = RemotePTYLifecycleRegistry() var lifecycleEndCallbacks: [RemotePTYLifecycleKey: @Sendable () -> Void] = [:] - init(remotePath: String, localPort: Int, startError: NSError?) { + init(remotePath: String, localPort: Int, credential: BrowserProxyCredential, startError: NSError?) { self.remotePath = remotePath self.localPort = localPort + self.credential = credential self.startError = startError } @@ -164,6 +166,7 @@ final class FakeTunnelProvider: RemoteProxyTunnelProviding, @unchecked Sendable configuration: WorkspaceRemoteConfiguration, remotePath: String, localPort: Int, + credential: BrowserProxyCredential, onFatalError: @escaping @Sendable (String) -> Void ) -> any RemoteProxyTunneling { lock.lock() @@ -175,7 +178,12 @@ final class FakeTunnelProvider: RemoteProxyTunnelProviding, @unchecked Sendable NSLocalizedDescriptionKey: "scripted start failure", ]) } - let tunnel = FakeProxyTunnel(remotePath: remotePath, localPort: localPort, startError: startError) + let tunnel = FakeProxyTunnel( + remotePath: remotePath, + localPort: localPort, + credential: credential, + startError: startError + ) _tunnels.append(tunnel) _onFatalErrors.append(onFatalError) return tunnel @@ -290,7 +298,11 @@ struct RemoteProxyBrokerTests { let tunnel = try #require(provider.tunnels.first) #expect(tunnel.startCount == 1) #expect(tunnel.remotePath == "/usr/local/bin/cmuxd-remote") - #expect(updates.last == .ready(BrowserProxyEndpoint(host: "127.0.0.1", port: tunnel.localPort))) + #expect(updates.last == .ready(BrowserProxyEndpoint( + host: "127.0.0.1", + port: tunnel.localPort, + credential: tunnel.credential + ))) } @Test("a second subscriber on the same transport shares the tunnel and gets .ready immediately") @@ -306,7 +318,8 @@ struct RemoteProxyBrokerTests { defer { leaseB.release() } #expect(provider.tunnels.count == 1) - let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: try #require(provider.tunnels.first).localPort) + let tunnel = try #require(provider.tunnels.first) + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: tunnel.localPort, credential: tunnel.credential) #expect(second.updates == [.ready(endpoint)]) } @@ -333,6 +346,9 @@ struct RemoteProxyBrokerTests { if case .ready = updates[1] {} else { Issue.record("expected .ready, got \(updates[1])") } #expect(updates[2] == .connecting) if case .ready = updates[3] {} else { Issue.record("expected .ready, got \(updates[3])") } + // A restarted tunnel mints its own credential. + let restarted = try #require(provider.tunnels.last) + #expect(try #require(provider.tunnels.first).credential != restarted.credential) } @Test("start failures publish errors with the legacy escalating retry suffixes and backoff delays") diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 69af82714ac4..67d41197652b 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -1,6 +1,7 @@ import CmuxCloud import AppKit import CmuxBrowser +import CmuxCore import CmuxSettings import Foundation import WebKit @@ -223,6 +224,14 @@ import WebKit didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { + if owner?.refusesProxyAuthenticationChallenges == true { + let disposition = ManagedProxySessionDelegate.disposition(for: challenge.protectionSpace) + if disposition == .cancelAuthenticationChallenge { + completionHandler(disposition, nil) + return + } + } + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let trust = challenge.protectionSpace.serverTrust, BrowserSSLTrustScope(protectionSpace: challenge.protectionSpace) != nil { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e00e96fb0044..222f2bbd8d48 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -834,6 +834,9 @@ func browserNewTabNavigationSeed( /// Mirrors the opener's WebKit browsing context for popup windows. struct BrowserPopupBrowserContext { let websiteDataStore: WKWebsiteDataStore + /// Mirrors ``BrowserPanel/refusesProxyAuthenticationChallenges`` for + /// popups sharing the opener's proxied store. + let refusesProxyAuthenticationChallenges: Bool } enum BrowserFileSystemAccessBridge { @@ -2899,7 +2902,8 @@ final class BrowserPanel: Panel, ObservableObject { /// Popups inherit this panel's exact WebKit storage context. var popupBrowserContext: BrowserPopupBrowserContext { BrowserPopupBrowserContext( - websiteDataStore: websiteDataStore + websiteDataStore: websiteDataStore, + refusesProxyAuthenticationChallenges: refusesProxyAuthenticationChallenges ) } @@ -4205,6 +4209,13 @@ final class BrowserPanel: Panel, ObservableObject { resumePendingRemoteNavigationIfNeeded() } + /// Remote and cloud panes route only through cmux proxies that carry + /// their credential, so a proxy challenge there is refused rather than + /// shown to the user. + var refusesProxyAuthenticationChallenges: Bool { + usesRemoteWorkspaceProxy || cloudBrowserMachineID != nil + } + func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { guard remoteWorkspaceStatus != status else { return } remoteWorkspaceStatus = status @@ -4229,18 +4240,33 @@ final class BrowserPanel: Panel, ObservableObject { return } + guard let configurations = Self.remoteWorkspaceProxyConfigurations(for: endpoint) else { + store.proxyConfigurations = [] + return + } + store.proxyConfigurations = [configurations.socks, configurations.connect] + } + + /// SOCKS5 and HTTP CONNECT configurations for the remote workspace proxy, + /// each carrying the tunnel credential the listener requires; nil when + /// the endpoint is unusable. + private static func remoteWorkspaceProxyConfigurations( + for endpoint: BrowserProxyEndpoint + ) -> (socks: ProxyConfiguration, connect: ProxyConfiguration)? { let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) guard !host.isEmpty, endpoint.port > 0 && endpoint.port <= 65535, let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { - store.proxyConfigurations = [] - return + return nil } let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) - store.proxyConfigurations = [socks, connect] + let credential = endpoint.credential + socks.applyCredential(username: credential.username, password: credential.password) + connect.applyCredential(username: credential.username, password: credential.password) + return (socks, connect) } private func beginDownloadActivity() { @@ -5725,20 +5751,23 @@ final class BrowserPanel: Panel, ObservableObject { } private func remoteProxyURLSession() -> URLSession? { - guard let endpoint = remoteProxyEndpoint else { return nil } - let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) - guard !host.isEmpty, endpoint.port > 0, endpoint.port <= 65535 else { return nil } + guard let endpoint = remoteProxyEndpoint, + let proxyConfigurations = Self.remoteWorkspaceProxyConfigurations(for: endpoint) else { + return nil + } let configuration = URLSessionConfiguration.ephemeral configuration.requestCachePolicy = .returnCacheDataElseLoad configuration.timeoutIntervalForRequest = 2.0 configuration.timeoutIntervalForResource = 4.0 - configuration.connectionProxyDictionary = [ - kCFNetworkProxiesSOCKSEnable as String: 1, - kCFNetworkProxiesSOCKSProxy as String: host, - kCFNetworkProxiesSOCKSPort as String: endpoint.port, - ] - return URLSession(configuration: configuration) + // SOCKS only, as before; the legacy proxy dictionary cannot carry + // the listener's credential. + configuration.proxyConfigurations = [proxyConfigurations.socks] + return URLSession( + configuration: configuration, + delegate: ManagedProxySessionDelegate(), + delegateQueue: nil + ) } private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index aeba931e78a8..1f3663e8931b 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -2,6 +2,7 @@ import CmuxCloud import AppKit import Bonsplit import CmuxBrowser +import CmuxCore import CmuxFoundation import CmuxSettings import ObjectiveC @@ -25,6 +26,9 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { let webView: CmuxWebView private let browserContext: BrowserPopupBrowserContext + var refusesProxyAuthenticationChallenges: Bool { + browserContext.refusesProxyAuthenticationChallenges + } private let panel: NSPanel private let urlLabel: NSTextField, urlLabelHeightConstraint: NSLayoutConstraint private weak var openerPanel: BrowserPanel? @@ -1154,6 +1158,14 @@ private final class PopupUIDelegate: BrowserPDFPreviewActionUIDelegate { didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { + if controller?.refusesProxyAuthenticationChallenges == true { + let disposition = ManagedProxySessionDelegate.disposition(for: challenge.protectionSpace) + if disposition == .cancelAuthenticationChallenge { + completionHandler(disposition, nil) + return + } + } + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, let trust = challenge.protectionSpace.serverTrust, BrowserSSLTrustScope(protectionSpace: challenge.protectionSpace) != nil { diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 35b9f83109fa..1f6d5d2ab1c0 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -2405,7 +2405,7 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) XCTAssertNil(panel.webView.url) - panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876, credential: .random())) let deadline = Date().addingTimeInterval(1.0) while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} @@ -2427,7 +2427,7 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) XCTAssertNil(panel.webView.url) - panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876, credential: .random())) let deadline = Date().addingTimeInterval(1.0) while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} @@ -2501,7 +2501,7 @@ final class BrowserPanelRemoteStoreTests: XCTestCase { XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) XCTAssertNil(panel.webView.url) - panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876, credential: .random())) let deadline = Date().addingTimeInterval(1.0) while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} diff --git a/daemon/remote/README.md b/daemon/remote/README.md index 81f0983238f4..d90a04bc5b79 100644 --- a/daemon/remote/README.md +++ b/daemon/remote/README.md @@ -46,7 +46,7 @@ When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the bin Current integration in cmux: 1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. 2. Client sends `hello` before enabling remote proxy transport. -3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. +3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. Both handshakes require a per-tunnel credential that only the embedded browser receives. 4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). 5. Persistent SSH terminals require the `pty.session.persistent_daemon` capability before cmux will restore a saved remote PTY session ID after relaunch. diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md index 3bad047168f0..39b21663b8e6 100644 --- a/docs/remote-daemon-spec.md +++ b/docs/remote-daemon-spec.md @@ -90,13 +90,14 @@ This is a **living implementation spec** (also called an **execution spec**): a ### 4.1 Browser Networking Path 1. `DONE` one local proxy endpoint is created per SSH transport/session key (not per detected port). 2. `DONE` endpoint is provided by a local broker that supports SOCKS5 + HTTP CONNECT and tunnels via daemon stream RPC. + Each tunnel start mints a random credential; SOCKS5 requires it through username/password authentication (RFC 1929) and HTTP CONNECT through `Proxy-Authorization: Basic`. Only the embedded browser receives it, so `workspace.remote.status` reports the endpoint without it. 3. `DONE` browser panels in remote workspaces are auto-wired to the workspace proxy endpoint. 4. `DONE` browser panels in local workspaces are not force-proxied. 5. `DONE` identical SSH transports share one endpoint via a transport-scoped broker. ### 4.2 WKWebView Wiring 1. `DONE` use workspace-scoped `WKWebsiteDataStore(forIdentifier:)`. -2. `DONE` apply workspace/browser scoped `proxyConfigurations`. +2. `DONE` apply workspace/browser scoped `proxyConfigurations`, each carrying the tunnel credential. 3. `DONE` prefer SOCKS5 proxy config. 4. `DONE` keep HTTP CONNECT proxy config as fallback. 5. `DONE` re-apply proxy config on reconnect/state updates.