Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ struct NeverReadyProxyTunnelProvider: RemoteProxyTunnelProviding {
configuration: WorkspaceRemoteConfiguration,
remotePath: String,
localPort: Int,
credential: BrowserProxyCredential,
onFatalError: @escaping @Sendable (String) -> Void
) -> any RemoteProxyTunneling {
NeverReadyProxyTunnel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ 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.
func makeTunnel(
configuration: WorkspaceRemoteConfiguration,
remotePath: String,
localPort: Int,
credential: BrowserProxyCredential,
onFatalError: @escaping @Sendable (String) -> Void
) -> any RemoteProxyTunneling
}
Loading
Loading