diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ae8aab96629..3fcb99bb9bc 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -1621,3 +1621,14 @@ The shared SSE decoder accepts LF, CRLF and standalone CR line endings, even whe Choosing an account-qualified model does not enable credit spending. Stored accounts obey **Use credits after limit** during authentication, when credentials are prepared, and before Responses HTTP or WebSocket dispatch after pacing or retry waits. A held request reports the credit policy, not an authentication failure; wait for the reset, choose another account, or explicitly enable that account’s credit spending. Stored-account vision and web-search helpers also recheck this policy before sending, including retries. If consent changes or a limit is reached after helper selection, the helper reports the policy refusal without sending that attempt. The standalone search relay returns a reset-bound 429. Caller-owned Direct credentials retain their existing behavior. + +## Native Desktop relay CONNECT validation + +The Desktop relay's existing HTTP/HTTPS CONNECT transport now rejects malformed +proxy response headers and ambiguous HTTP Basic usernames containing colons. +Decoded credential pairs are limited to 4096 UTF-8 bytes. If your selected proxy +is rejected, correct its configuration or response; the relay does not silently +switch to a direct connection. + +Proxy selection, SOCKS5/SOCKS5h support, certificate verification, and connection +lifecycle are unchanged. Both SOCKS spellings retain remote target DNS resolution. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index cec9c716c3f..c077bccfac3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -855,6 +855,8 @@ "deepseek-responses-item-id-repair.test.ts": "providers", "management-desktop-compatibility-routes.test.ts": "server", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "optional-desktop-upstream.test.ts": "lib", + "opaque-proxy-tunnel.test.ts": "lib", + "opaque-proxy-tunnel-protocol.test.ts": "lib", "destination-policy-resolved.test.ts": "routing", "devin-adapter-reset-wait.test.ts": "providers", "devin-adapter.test.ts": "providers", diff --git a/src/lib/desktop-upstream-tunnel.ts b/src/lib/desktop-upstream-tunnel.ts index 936ccb0b2f3..a9b03db7e7e 100644 --- a/src/lib/desktop-upstream-tunnel.ts +++ b/src/lib/desktop-upstream-tunnel.ts @@ -1,10 +1,10 @@ -import { connect as tcpConnect, isIP, type Socket } from "node:net"; -import { checkServerIdentity, connect as tlsConnect, type TLSSocket } from "node:tls"; +import { connect as tcpConnect, type Socket } from "node:net"; +import { connect as tlsConnect, type TLSSocket } from "node:tls"; import { desktopProxyFor } from "./desktop-proxy-route"; -import { socks5Credentials, socks5Handshake } from "./socks5-handshake"; +import { dialTunnel, type OpaqueProxyRoute } from "./opaque-proxy-tunnel"; -// Shares the SOCKS exchange extracted by lcxhh521 in PR #5947 (efdccdbfac3f). -// Lifecycle here additionally bounds the entire dial and handles close/abort/header limits. +// The shared opaque dialer owns proxy framing; this caller owns fixed-target +// TLS and bounds the entire connection, including the target TLS handshake. export interface DesktopTunnelOptions { proxy?: string | false; signal?: AbortSignal; @@ -15,37 +15,6 @@ export interface DesktopTunnelOptions { proxyCa?: string; } const fail = () => new Error("desktop_upstream_connection_failed"); -class HandshakeReader { - private buffer: Buffer = Buffer.alloc(0); - private failure: Error | null = null; - private waiting: { ready: () => boolean; resolve: () => void; reject: (error: Error) => void } | null = null; - private onData = (chunk: Buffer) => { - if (this.buffer.length + chunk.length > 65536) { this.onFailure(); return; } - this.buffer = Buffer.concat([this.buffer, chunk]); - if (this.waiting?.ready()) { const current = this.waiting; this.waiting = null; current.resolve(); } - }; - private onFailure = () => { this.failure = fail(); const current = this.waiting; this.waiting = null; current?.reject(this.failure); }; - constructor(private socket: Socket, private signal: AbortSignal) { - socket.on("data", this.onData); socket.once("error", this.onFailure); socket.once("end", this.onFailure); socket.once("close", this.onFailure); - signal.addEventListener("abort", this.onFailure, { once: true }); if (signal.aborted) this.onFailure(); socket.resume(); - } - write(bytes: Uint8Array | string): void { if (this.failure) throw this.failure; this.socket.write(bytes); } - private async until(ready: () => boolean): Promise { - if (this.failure) throw this.failure; - if (ready()) return; - if (this.waiting) throw fail(); - await new Promise((resolve, reject) => { this.waiting = { ready, resolve, reject }; }); - } - private take(length: number): Buffer { const out = this.buffer.subarray(0, length); this.buffer = this.buffer.subarray(length); return out; } - async readExact(length: number): Promise { await this.until(() => this.buffer.length >= length); return this.take(length); } - async readHead(): Promise { await this.until(() => this.buffer.indexOf("\r\n\r\n") >= 0); return this.take(this.buffer.indexOf("\r\n\r\n") + 4).toString("latin1"); } - dispose(): void { - this.socket.pause(); this.socket.removeListener("data", this.onData); this.socket.removeListener("error", this.onFailure); - this.socket.removeListener("end", this.onFailure); this.socket.removeListener("close", this.onFailure); - this.signal.removeEventListener("abort", this.onFailure); - if (this.buffer.length) this.socket.unshift(this.buffer); this.buffer = Buffer.alloc(0); this.onFailure(); - } -} function waitConnected(socket: Socket, event: "connect" | "secureConnect", signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const dispose = () => { socket.off(event, done); socket.off("error", failure); socket.off("close", failure); signal.removeEventListener("abort", failure); }; @@ -76,25 +45,21 @@ export async function dialDesktopUpstream(options: DesktopTunnelOptions = {}): P const url = new URL(proxy), protocol = url.protocol; if (!["http:", "https:", "socks5:", "socks5h:"].includes(protocol) || !url.hostname || url.search || url.hash || url.pathname !== "" && url.pathname !== "/") throw fail(); const host = url.hostname.replace(/^\[|\]$/g, ""), port = Number(url.port) || (protocol === "https:" ? 443 : protocol.startsWith("socks") ? 1080 : 80); - raw = own(tcpConnect({ host, port })); await waitConnected(raw, "connect", signal); - if (protocol === "https:") { - raw = own(tlsConnect({ socket: raw, servername: isIP(host) ? undefined : host, ca: options.proxyCa, - rejectUnauthorized: true, checkServerIdentity: (_name, certificate) => checkServerIdentity(host, certificate), ALPNProtocols: ["http/1.1"] })); - await waitConnected(raw, "secureConnect", signal); - } - const reader = new HandshakeReader(raw, signal); - try { - if (protocol.startsWith("socks")) await socks5Handshake(reader, target, socks5Credentials(url)); - else { - const authority = `${target.host}:${target.port}`; - const lines = [`CONNECT ${authority} HTTP/1.1`, `Host: ${authority}`]; - if (url.username || url.password) lines.push(`Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`).toString("base64")}`); - reader.write(lines.join("\r\n") + "\r\n\r\n"); - if (!/^HTTP\/1\.[01] 2\d\d(?: |\r)/.test(await reader.readHead())) throw fail(); - } - } finally { reader.dispose(); } + const credentials = url.username || url.password + ? { username: decodeURIComponent(url.username), password: decodeURIComponent(url.password) } + : {}; + const endpoint = { host, port, ...credentials }; + const route: OpaqueProxyRoute = protocol === "https:" + ? { ...endpoint, kind: "https", proxyCa: options.proxyCa } + : { ...endpoint, kind: protocol.slice(0, -1) as "http" | "socks5" | "socks5h" }; + const authority = `${target.host.includes(":") ? `[${target.host}]` : target.host}:${target.port}`; + // The route resolver remains authoritative; a failed proxy never becomes DIRECT. + raw = own(await dialTunnel(authority, route, { signal, timeoutMs: timeout, maxHeaderBytes: 65536 })); } if (signal.aborted || raw.destroyed) throw fail(); + // This consumer historically closes its writable side after peer EOF. The + // shared opaque transport preserves half-closes for callers that need them. + raw.allowHalfOpen = false; const secured = own(tlsConnect({ socket: raw, servername: "chatgpt.com", ca: options.ca, rejectUnauthorized: true, ALPNProtocols: ["http/1.1"] })); // The CONNECT reader paused its stream before handing buffered bytes back. Bun's // TLS-over-TLS path needs the outer TLS stream explicitly resumed for the new handshake. diff --git a/src/lib/opaque-proxy-tunnel.ts b/src/lib/opaque-proxy-tunnel.ts new file mode 100644 index 00000000000..abdeb49ab53 --- /dev/null +++ b/src/lib/opaque-proxy-tunnel.ts @@ -0,0 +1,217 @@ +/** + * Single-route opaque proxy tunnel. The desktop upstream dialer retains its + * own fixed-target TLS contract. + * Shares OpenCodex's MIT SOCKS handshake; the caller owns all post-establishment + * cancellation and socket cleanup. Never replay or implicitly retry directly. + */ +import net, { type Socket } from 'node:net'; +import tls from 'node:tls'; +import { socks5Handshake, socks5Credentials, Socks5HandshakeError } from './socks5-handshake'; + +interface RouteEndpoint { host: string; port: number } +type RouteCredentials = { username?: undefined; password?: undefined } | { username: string; password: string }; +type PlainProxy = { kind?: 'http'; proxyCa?: never }; +type SecureProxy = { kind: 'https'; proxyCa?: string }; +type SocksProxy = { kind: 'socks5' | 'socks5h'; proxyCa?: never }; +export type OpaqueProxyRoute = RouteEndpoint & RouteCredentials & (PlainProxy | SecureProxy | SocksProxy); +export interface OpaqueTunnelOptions { + signal?: AbortSignal; + timeoutMs?: number; + maxHeaderBytes?: number; +} +interface AttemptOptions { + signal?: AbortSignal; + timeoutMs: number; + maxHeaderBytes: number; +} +export class TunnelError extends Error { + override readonly name = 'TunnelError'; + readonly code: string; + readonly status?: number; + constructor(code: string, message: string, { status }: { status?: number } = {}) { + super(message); this.code = code; this.status = status; + } +} +const aborted = () => new TunnelError('ABORTED', 'Tunnel establishment cancelled'); + +function validateAuthority(authority: string): string { + if (typeof authority !== 'string' || !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9._-]+):[0-9]+$/.test(authority)) { + throw new TunnelError('TARGET', 'An explicit host:port authority is required'); + } + const port = Number(authority.slice(authority.lastIndexOf(':') + 1)); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TunnelError('TARGET', 'Invalid target port'); + return authority; +} + +function validateOptions(p: OpaqueProxyRoute, timeoutMs: number, maxHeaderBytes: number): void { + if (!p || Array.isArray(p) || typeof p.host !== 'string' || !p.host || /[\s/@]/.test(p.host) + || !Number.isInteger(p.port) || p.port < 1 || p.port > 65535 + || !['http', 'https', 'socks5', 'socks5h'].includes(p.kind ?? 'http')) throw new TunnelError('CONFIG', 'Invalid proxy route'); + if ((p.username === undefined) !== (p.password === undefined) + || (p.username !== undefined && (typeof p.username !== 'string' || typeof p.password !== 'string' + || (!p.kind?.startsWith('socks') && p.username.includes(':')) || Buffer.byteLength(p.username + p.password) > 4096))) throw new TunnelError('CONFIG', 'Invalid proxy credentials'); + if ((p.kind ?? 'http').startsWith('socks') && p.username !== undefined + && (Buffer.byteLength(p.username) > 255 || Buffer.byteLength(p.password) > 255)) throw new TunnelError('CONFIG', 'SOCKS credentials must each fit in 255 UTF-8 bytes'); + if (p.proxyCa !== undefined && p.kind !== 'https') throw new TunnelError('CONFIG', 'Proxy TLS settings require HTTPS proxy'); + if (p.proxyCa !== undefined && typeof p.proxyCa !== 'string') throw new TunnelError('CONFIG', 'Invalid proxy CA'); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 + || !Number.isSafeInteger(maxHeaderBytes) || maxHeaderBytes < 64 || maxHeaderBytes > 65536) { + throw new TunnelError('CONFIG', 'Invalid deadline or header limit'); + } +} + +function connectOne(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs, maxHeaderBytes }: AttemptOptions): Promise { + if (proxy.kind === 'socks5' || proxy.kind === 'socks5h') return connectSocks(authority, proxy, { signal, timeoutMs, maxHeaderBytes }); + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(aborted()); + const secure = proxy.kind === 'https'; + const identity = proxy.host; + const tlsOptions: tls.ConnectionOptions & { allowHalfOpen: boolean } = { + host: proxy.host, port: proxy.port, allowHalfOpen: true, + servername: net.isIP(identity) ? undefined : identity, ca: proxy.proxyCa, + checkServerIdentity: (_name, cert) => tls.checkServerIdentity(identity, cert), + rejectUnauthorized: true, ALPNProtocols: ['http/1.1'], + }; + const socket = secure + ? tls.connect(tlsOptions) + : net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true }); + // Keep a defensive observer across the promise-to-owner handoff. Errors still + // settle the establishment promise or are handled by the relay after handoff. + socket.on('error', () => {}); + let pending: Buffer = Buffer.alloc(0); let settled = false, tlsReady = !secure; + const connectedEvent = secure ? 'secureConnect' : 'connect'; + const cleanup = () => { + clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + socket.removeListener(connectedEvent, onConnect); socket.removeListener('data', onData); + socket.removeListener('error', onError); socket.removeListener('end', onEnd); socket.removeListener('close', onClose); + }; + const fail = (error: Error) => { + if (settled) return; settled = true; cleanup(); socket.destroy(); reject(error); + }; + const onAbort = () => fail(aborted()); + const establishmentFailure = (code: string, message: string) => fail(new TunnelError(code, message)); + const onError = () => { + if (secure && !tlsReady) { + fail(new TunnelError('PROXY_TLS', 'HTTPS proxy establishment failed')); + } else establishmentFailure('TRANSPORT', 'HTTP proxy connection failed'); + }; + const onEnd = () => establishmentFailure('EARLY_CLOSE', 'HTTP proxy closed before establishing the tunnel'); + const onClose = onEnd; + const onConnect = () => { + tlsReady = true; + const auth = proxy.username === undefined ? '' : `Proxy-Authorization: Basic ${Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64')}\r\n`; + socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${auth}\r\n`); + }; + const onData = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const lineEnd = pending.indexOf('\r\n'); + if (lineEnd >= 0) { + const firstLine = pending.subarray(0, lineEnd).toString('latin1'); + const firstStatus = /^HTTP\/1\.[01] ([0-9]{3})(?: [\t\x20-\x7e\x80-\xff]*)?$/.exec(firstLine); + if (!firstStatus) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line')); + const status = Number(firstStatus[1]); + if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); + } + const end = pending.indexOf('\r\n\r\n'); + if (end < 0) { + if (pending.length > maxHeaderBytes) fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); + return; + } + if (end + 4 > maxHeaderBytes) return fail(new TunnelError('PROTOCOL', 'HTTP proxy response headers exceed limit')); + const lines = pending.subarray(0, end).toString('latin1').split('\r\n'); + const statusLine = lines[0]; + const match = /^HTTP\/1\.[01] ([0-9]{3})(?: [^\r\n]*)?$/.exec(statusLine); + if (!match) return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy status line')); + const status = Number(match[1]); + if (lines.slice(1).some(line => !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+:[\t\x20-\x7e\x80-\xff]*$/.test(line))) { + return fail(new TunnelError('PROTOCOL', 'Invalid HTTP proxy response header')); + } + if (status < 200 || status >= 300) return fail(new TunnelError('REFUSED', 'HTTP proxy refused CONNECT', { status })); + settled = true; socket.pause(); cleanup(); + const head = pending.subarray(end + 4); + if (head.length) socket.unshift(head); + resolve(socket); + }; + const timer = setTimeout(() => establishmentFailure('TIMEOUT', 'HTTP proxy establishment deadline exceeded'), timeoutMs); + socket.once(connectedEvent, onConnect); socket.on('data', onData); + socket.once('error', onError); socket.once('end', onEnd); socket.once('close', onClose); + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); +} + +// The bounded socket reader owns setup cancellation and preserves queued bytes. +// Framing uses the existing +// MIT socks5-handshake module; neither shared framing nor its DNS policy changes. +async function connectSocks(authority: string, proxy: OpaqueProxyRoute, { signal, timeoutMs }: AttemptOptions): Promise { + if (signal?.aborted) throw aborted(); + const socket = net.createConnection({ host: proxy.host, port: proxy.port, allowHalfOpen: true }); + socket.on('error', () => {}); + let buffer: Buffer = Buffer.alloc(0), failure: Error | undefined; + let waiting: { length: number; resolve: () => void; reject: (error: Error) => void } | undefined; + const fail = (error: Error) => { if (failure) return; failure = error; const current = waiting; waiting = undefined; current?.reject(error); socket.destroy(); }; + const transportError = (code: string, message: string) => fail(new TunnelError(code, message)); + const onData = (chunk: Buffer) => { + // Consume at most the bytes needed by a bounded handshake read, then pause. + // A valid CONNECT reply can share a packet with arbitrary early target data; + // that remainder is not a malformed/oversized handshake and must survive. + socket.pause(); + buffer = Buffer.concat([buffer, chunk]); + if (waiting && buffer.length >= waiting.length) { const current = waiting; waiting = undefined; current.resolve(); } + else if (waiting) socket.resume(); + }; + const onError = () => transportError('TRANSPORT', 'SOCKS proxy connection failed'); + const onClose = () => transportError('EARLY_CLOSE', 'SOCKS proxy closed before tunnel establishment'); + const onAbort = () => fail(aborted()); + const timer = setTimeout(() => transportError('TIMEOUT', 'SOCKS establishment deadline exceeded'), timeoutMs); + socket.on('data', onData); socket.once('error', onError); socket.once('end', onClose); socket.once('close', onClose); + socket.pause(); + signal?.addEventListener('abort', onAbort, { once: true }); if (signal?.aborted) onAbort(); + const reader = { + write(bytes: Uint8Array): void { if (failure) throw failure; socket.write(bytes); }, + async readExact(length: number): Promise { + if (failure) throw failure; + if (!Number.isSafeInteger(length) || length < 0 || length > 1024) throw new TunnelError('PROTOCOL', 'Invalid handshake read size'); + if (buffer.length < length) await new Promise((resolve, reject) => { + if (waiting) { reject(new TunnelError('PROTOCOL', 'Concurrent handshake read')); return; } + waiting = { length, resolve, reject }; + socket.resume(); + }); + if (failure) throw failure; + const result = buffer.subarray(0, length); buffer = buffer.subarray(length); return result; + }, + }; + try { + const credentialsUrl = new URL('socks5://fixture.invalid'); + if (proxy.username !== undefined) { credentialsUrl.username = encodeURIComponent(proxy.username); credentialsUrl.password = encodeURIComponent(proxy.password); } + const colon = authority.lastIndexOf(':'); + const target = { host: authority.slice(0, colon).replace(/^\[|\]$/g, ''), port: Number(authority.slice(colon + 1)) }; + await socks5Handshake(reader, target, socks5Credentials(credentialsUrl), signal); + if (failure) throw failure; + if (signal?.aborted || socket.destroyed) throw aborted(); + socket.pause(); if (buffer.length) socket.unshift(buffer); buffer = Buffer.alloc(0); + return socket; + } catch (error) { + socket.destroy(); + if (error instanceof Socks5HandshakeError) throw new TunnelError('SOCKS_REFUSED', 'SOCKS authentication, policy, or protocol refusal'); + throw error; + } finally { + clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + socket.removeListener('data', onData); socket.removeListener('error', onError); + socket.removeListener('end', onClose); socket.removeListener('close', onClose); + } +} + +/** Establish only. There is no application data, implicit DIRECT, or replay here. + * HTTP/HTTPS CONNECT and SOCKS5/SOCKS5h yield an opaque raw target tunnel. + */ +export async function dialTunnel(authority: string, proxy: OpaqueProxyRoute, options: OpaqueTunnelOptions = {}): Promise { + const { signal, timeoutMs = 10000, maxHeaderBytes = 65536 } = options; + validateAuthority(authority); + validateOptions(proxy, timeoutMs, maxHeaderBytes); + if (signal?.aborted) throw aborted(); + // Snapshot the selected route. No alternate proxy or direct fallback exists. + const socket = await connectOne(authority, { ...proxy }, { signal, timeoutMs, maxHeaderBytes }); + if (signal?.aborted || socket.destroyed) { socket.destroy(); throw aborted(); } + return socket; +} diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 620386c14b9..9481c2a8c7a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -427,11 +427,41 @@ refuses. A failed selected route never retries directly. Identity verification u HTTP path, while provider inference retains its separate existing provider-egress policy. `src/lib/desktop-upstream-tunnel.ts` dials fixed chatgpt.com TLS through HTTP/HTTPS CONNECT -or authenticated SOCKS5. It bounds the whole connection setup, rejects proxy EOF, abort and +or authenticated SOCKS5. Its selected-proxy branch delegates handshake framing to +`src/lib/opaque-proxy-tunnel.ts` with one explicit route; +its direct branch and final target TLS handshake remain locally owned. It bounds +the whole connection setup, rejects proxy EOF, abort and oversized handshake heads, and verifies both proxy and upstream certificate identities. Only the proxy handshake carries proxy credentials. The raw upgraded socket remains opaque; the relay does not decode WebSocket payloads. After CONNECT the paused outer stream resumes before TLS-over-TLS negotiation. Test fixture CA trust is confined to child processes or injected test socket options, never the user's OS trust store. +`tests/lib/optional-desktop-upstream.test.ts` exercises the production dialer; `tests/clients/desktop-compatibility-relay.test.ts` +preserves the real relay’s fixed-target and opaque upgrade contracts. + +## Single-route opaque proxy transport + +`src/lib/opaque-proxy-tunnel.ts` establishes one HTTP/HTTPS CONNECT or SOCKS5/SOCKS5h +route selected by `src/lib/desktop-upstream-tunnel.ts`. It returns a paused socket; +the caller owns target selection and TLS, cancellation after establishment, +backpressure, half-close and final destruction. The dialer has no proxy discovery, +alternate-route list, direct fallback, listener, route version or session manager. +The existing relay retains connection lifecycle ownership. + +The handshake is bounded by a timeout and optional caller signal. Proxy credentials +are request-local and excluded from diagnostics. HTTPS proxy identity is verified +against its configured host. HTTP Basic usernames with colons and malformed CONNECT +response headers are rejected. Header size remains bounded even when a delimiter +and early target bytes arrive together; remaining bytes are handed to the caller. + +`src/lib/socks5-handshake.ts` owns RFC 1928/1929 framing. Both SOCKS spellings use its +remote-DNS behavior and existing zero-to-255-byte credential-field limits, including +colon usernames. Bounded reads preserve coalesced opaque bytes after CONNECT. +There is no retry or replay before or after establishment. The caller retains the +fixed target, explicit DIRECT selection and whole-connection deadline described above. +`tests/lib/opaque-proxy-tunnel.test.ts` and +`tests/lib/opaque-proxy-tunnel-protocol.test.ts` cover framing, TLS, authentication, +timeout, cancellation and half-close; `tests/lib/optional-desktop-upstream.test.ts` +checks all four proxy schemes close cleanly when the production consumer sees EOF. Generic OAuth conversation ownership and request-local selection receipts follow [generic OAuth affinity](../providers/generic-account-pool.md#conversation-affinity). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b9be122d390..d0b3f1a88b4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1320,6 +1320,8 @@ "management-desktop-compatibility-runtime-routes.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", "optional-desktop-upstream.test.ts": "lib", + "opaque-proxy-tunnel.test.ts": "lib", + "opaque-proxy-tunnel-protocol.test.ts": "lib", "server-desktop-compatibility-startup.test.ts": "server", "codex-set-prompt.test.ts": "gui", "codex-prompt-writes.test.ts": "codex-integration", "codex-prompt-scalar-scope.test.ts": "codex-integration", "advisor-settings.test.ts": "advisor", "advisor-context.test.ts": "advisor", "advisor-state.test.ts": "advisor", "advisor-guard.test.ts": "advisor", "advisor-consult.test.ts": "advisor", "advisor-plan.test.ts": "advisor", "advisor-responses-wiring.test.ts": "advisor", "advisor-routes.test.ts": "server", "advisor-internal-authority.test.ts": "advisor", "advisor-core-boundary.test.ts": "advisor", "advisor-continuation-limit.test.ts": "advisor", "advisor-authority-transport.test.ts": "advisor", "advisor-evidence.test.ts": "advisor", "advisor-budget.test.ts": "advisor", "advisor-adaptive.test.ts": "advisor" diff --git a/tests/helpers/desktop-egress-fixture.ts b/tests/helpers/desktop-egress-fixture.ts index fd2e60d083c..7be3eadecdd 100644 --- a/tests/helpers/desktop-egress-fixture.ts +++ b/tests/helpers/desktop-egress-fixture.ts @@ -9,7 +9,7 @@ export async function listenFixture(server: Server, host = "127.0.0.1") { return { port: (server.address() as { port: number }).port, async close() { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(() => resolve())); } }; } -export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort: number, leaf: PemKeyPair) { +export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort: number, leaf: PemKeyPair, credentials = {username: "fixture", password: "secret"}) { const sockets = new Set(), seen: { authority: string; auth: string }[] = []; const pipe = (client: Socket, head = Buffer.alloc(0)) => { const target = connect({ host: "127.0.0.1", port: targetPort }); sockets.add(target); @@ -29,8 +29,9 @@ export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort } else if (stage === 1) { if (held.length < 2 || held.length < 3 + held[1]!) return; const n = held[1]!, m = held[2 + n]!; if (held.length < 3 + n + m) return; - auth = held.subarray(2, 2 + n).toString() + ":" + held.subarray(3 + n, 3 + n + m).toString(); - held = held.subarray(3 + n + m); client.write(Buffer.from([1, auth === "fixture:secret" ? 0 : 1])); stage = 2; + const username = held.subarray(2, 2 + n).toString(), password = held.subarray(3 + n, 3 + n + m).toString(); + auth = `${username}:${password}`; + held = held.subarray(3 + n + m); client.write(Buffer.from([1, username === credentials.username && password === credentials.password ? 0 : 1])); stage = 2; } else { if (held.length < 5 || held.length < 7 + held[4]!) return; const n = held[4]!, authority = held.subarray(5, 5 + n).toString() + ":" + held.readUInt16BE(5 + n); @@ -43,10 +44,10 @@ export async function forwardProxy(kind: "http" | "https" | "socks5", targetPort }) : kind === "https" ? createHttps({ cert: leaf.certPem, key: leaf.keyPem }) : createHttp(); if (kind !== "socks5") server.on("connect", (req, client, head) => { const auth = String(req.headers["proxy-authorization"] ?? ""); seen.push({ authority: req.url ?? "", auth }); - if (auth !== "Basic " + Buffer.from("fixture:secret").toString("base64")) { client.end("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"); return; } + if (auth !== "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")) { client.end("HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"); return; } client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); pipe(client as Socket, head); }); const listening = await listenFixture(server, kind === "https" ? "localhost" : "127.0.0.1"); - return { ...listening, seen, url: `${kind}://fixture:secret@${kind === "https" ? "localhost" : "127.0.0.1"}:${listening.port}`, + return { ...listening, seen, url: `${kind}://${encodeURIComponent(credentials.username)}:${encodeURIComponent(credentials.password)}@${kind === "https" ? "localhost" : "127.0.0.1"}:${listening.port}`, async close() { for (const socket of sockets) socket.destroy(); await listening.close(); } }; } diff --git a/tests/helpers/opaque-proxy-tunnel-protocol.ts b/tests/helpers/opaque-proxy-tunnel-protocol.ts new file mode 100644 index 00000000000..4f7a7d28c1e --- /dev/null +++ b/tests/helpers/opaque-proxy-tunnel-protocol.ts @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import net, { type Socket } from "node:net"; +import tls from "node:tls"; +import { once } from "node:events"; +import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; +import type { OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; +import { listen, TunnelFixtureScope } from "./opaque-proxy-tunnel"; + +// Runtime-only keys, never persisted or added to an OS trust store. Proxy and target +// use separate leaf identities; clients explicitly trust only this fixture CA. +const commonName = "opaque tunnel fixture CA"; +const authority = createCertificateAuthority({ commonName, validityDays: 1 }); +export const ca = authority.certPem; +const originIdentity = issueServerLeaf(authority, commonName, ["origin.fixture.invalid"]); +const wrongProxyIdentity = issueServerLeaf(authority, commonName, ["wrong.fixture.invalid"]); +const proxyIdentity = issueServerLeaf(authority, commonName, ["127.0.0.1"]); +export const credentials = { username: "fixture-user", password: "fixture-páss" }; +const basic = "Basic " + Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64"); +export type ProxyKind = "http" | "https" | "socks5" | "socks5h"; +export const proxyKinds: readonly ProxyKind[] = ["http", "https", "socks5", "socks5h"]; + +export async function origin(scope: TunnelFixtureScope, { slow = false, drop = false } = {}) { + const sockets = new Set(), received: Buffer[] = []; + let calls = 0; + const server = tls.createServer({ key: originIdentity.keyPem, cert: originIdentity.certPem, allowHalfOpen: true }, socket => { + calls++; + sockets.add(socket); + socket.on("error", () => {}); + socket.once("close", () => sockets.delete(socket)); + let writes = 0, ended = false; + socket.on("data", (chunk: Buffer) => { + received.push(chunk); + if (drop) { socket.destroy(); return; } + if (slow) { + writes++; + socket.pause(); + scope.later(() => { + writes--; + if (socket.destroyed) return; + socket.write(chunk); + if (ended && !writes) socket.end(); else socket.resume(); + }, 2); + } else socket.write(chunk); + }); + socket.once("end", () => { ended = true; if (!writes) socket.end(); }); + }); + server.on("tlsClientError", (_error, socket) => socket.destroy()); + const port = await listen(scope, server, sockets); + return { port, authority: `origin.fixture.invalid:${port}`, received, calls: () => calls }; +} + +type ProxyOptions = { + mode?: "ok" | "hang" | "partial" | "refuse" | "policy" | "early-large"; + destination?: Awaited>; + requireAuth?: boolean; + fragmented?: boolean; + wrongIdentity?: boolean; +}; + +export async function protocolProxy(scope: TunnelFixtureScope, kind: ProxyKind, { + mode = "ok", destination, requireAuth = true, fragmented = false, wrongIdentity = false, +}: ProxyOptions = {}) { + const sockets = new Set(); + const seen = { connections: 0, handshakes: 0, auth: 0, targets: [] as Array<{ host: string; port: number }>, opaque: [] as Buffer[], applicationBytes: 0, clientEnds: 0 }; + const own = (socket: Socket) => { + sockets.add(socket); + socket.on("error", () => {}); + socket.once("close", () => sockets.delete(socket)); + return socket; + }; + const handle = (socket: Socket) => { + socket.allowHalfOpen = true; + own(socket); + seen.handshakes++; + let buffer = Buffer.alloc(0), stage = 0; + const send = (bytes: Buffer | number[]) => { + const data = Buffer.from(bytes); + if (!fragmented) socket.write(data); + else for (let i = 0; i < data.length; i++) { + scope.later(() => { if (!socket.destroyed) socket.write(data.subarray(i, i + 1)); }, i); + } + }; + socket.once("end", () => { seen.clientEnds++; }); + const onEnd = () => socket.end(); + socket.once("end", onEnd); + const tunnel = (host: string, port: number, head = Buffer.alloc(0)) => { + seen.targets.push({ host, port }); + socket.removeListener("data", onData); + socket.pause(); + if (mode === "early-large") { + socket.write(Buffer.concat([Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]), Buffer.alloc(192 * 1024, 0xa5)])); + return; + } + assert.ok(destination, "test destination required"); + assert.equal(host, "origin.fixture.invalid"); + assert.equal(port, destination.port); + // All target routing stays on loopback. The .invalid name is forwarded verbatim. + const upstream = own(net.connect({ port: destination.port, host: "127.0.0.1", allowHalfOpen: true })); + upstream.once("connect", () => { + const connected = kind.startsWith("socks") + ? Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]) + : Buffer.from("HTTP/1.1 200 Established\r\n\r\n"); + send(connected); + scope.later(() => { + if (socket.destroyed || upstream.destroyed) return; + socket.removeListener("end", onEnd); + socket.on("data", (chunk: Buffer) => seen.opaque.push(chunk)); + socket.once("close", () => { if (!socket.readableEnded) upstream.destroy(); }); + upstream.once("close", () => { if (!upstream.readableEnded) socket.destroy(); }); + socket.once("error", () => upstream.destroy()); + upstream.once("error", () => socket.destroy()); + if (head.length) socket.unshift(head); + socket.pipe(upstream); + upstream.pipe(socket); + }, fragmented ? connected.length + 3 : 0); + }); + }; + const onData = (data: Buffer) => { + seen.applicationBytes += data.length; + buffer = Buffer.concat([buffer, data]); + if (mode === "hang") return; + if (mode === "partial") { + socket.removeListener("data", onData); + socket.write(kind.startsWith("socks") ? Buffer.from([5]) : "HTTP/1.1 4"); + return; + } + if (!kind.startsWith("socks")) { + const end = buffer.indexOf("\r\n\r\n"); + if (end < 0) return; + const header = buffer.subarray(0, end).toString("latin1"); + if (mode === "refuse" || (requireAuth && !header.includes(`Proxy-Authorization: ${basic}`))) { + socket.end("HTTP/1.1 407 Authentication Required\r\n\r\n"); + return; + } + if (requireAuth) seen.auth++; + const requested = /^CONNECT ([^ ]+) HTTP\/1\.1/.exec(header)?.[1]; + assert.ok(requested); + const colon = requested.lastIndexOf(":"); + tunnel(requested.slice(0, colon), Number(requested.slice(colon + 1)), buffer.subarray(end + 4)); + return; + } + for (;;) { + if (stage === 0) { + if (buffer.length < 2 || buffer.length < 2 + buffer[1]) return; + assert.equal(buffer[0], 5); + if (requireAuth) assert.ok(buffer.subarray(2, 2 + buffer[1]).includes(2)); + buffer = buffer.subarray(2 + buffer[1]); + send([5, requireAuth ? 2 : 0]); + stage = requireAuth ? 1 : 2; + } else if (stage === 1) { + if (buffer.length < 2) return; + const userLength = buffer[1]; + if (buffer.length < 3 + userLength) return; + const passwordLength = buffer[2 + userLength]; + if (buffer.length < 3 + userLength + passwordLength) return; + const username = buffer.subarray(2, 2 + userLength).toString(); + const password = buffer.subarray(3 + userLength, 3 + userLength + passwordLength).toString(); + assert.equal(buffer[0], 1); + buffer = buffer.subarray(3 + userLength + passwordLength); + if (mode === "refuse" || username !== credentials.username || password !== credentials.password) { + socket.end(Buffer.from([1, 1])); + return; + } + seen.auth++; + send([1, 0]); + stage = 2; + } else if (stage === 2) { + if (buffer.length < 5 || buffer.length < 7 + buffer[4]) return; + assert.deepEqual([...buffer.subarray(0, 4)], [5, 1, 0, 3]); + const length = buffer[4]; + const host = buffer.subarray(5, 5 + length).toString(); + const port = buffer.readUInt16BE(5 + length); + if (mode === "policy") { socket.end(Buffer.from([5, 2, 0, 1, 0, 0, 0, 0, 0, 0])); return; } + tunnel(host, port, buffer.subarray(7 + length)); + stage = 3; + return; + } else return; + } + }; + socket.on("data", onData); + }; + // Pre-handshake TLS peers auto-close; half-close is enabled only after secureConnect. + const server = kind === "https" + ? tls.createServer({ key: (wrongIdentity ? wrongProxyIdentity : proxyIdentity).keyPem, cert: (wrongIdentity ? wrongProxyIdentity : proxyIdentity).certPem }, handle) + : net.createServer({ allowHalfOpen: true }, handle); + server.on("connection", () => { seen.connections++; }); + server.on("tlsClientError", (_error, socket) => socket.destroy()); + const port = await listen(scope, server, sockets); + const base = { host: "127.0.0.1", port }; + let route: OpaqueProxyRoute; + switch (kind) { + case "https": route = { ...base, kind, proxyCa: ca }; break; + case "http": route = { ...base, kind }; break; + default: route = { ...base, kind }; break; + } + const candidate: OpaqueProxyRoute = requireAuth ? { ...route, ...credentials } : route; + return { candidate, seen, sockets }; +} + +export async function securedClient(scope: TunnelFixtureScope, raw: Socket, servername = "origin.fixture.invalid", trust = ca) { + scope.own(raw); + const options = { socket: raw, servername, ca: trust, rejectUnauthorized: true, allowHalfOpen: true }; + const socket = scope.own(tls.connect(options)); + raw.resume(); + await once(socket, "secureConnect"); + assert.equal(socket.authorized, true); + return socket; +} diff --git a/tests/helpers/opaque-proxy-tunnel.ts b/tests/helpers/opaque-proxy-tunnel.ts new file mode 100644 index 00000000000..aa9c0eed1b4 --- /dev/null +++ b/tests/helpers/opaque-proxy-tunnel.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import net, { type AddressInfo, type Server, type Socket } from "node:net"; +import { once } from "node:events"; +import { createHash } from "node:crypto"; +import type { OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; + +/** Each case owns its listeners, peers, client sockets, and fixture timers. */ +export class TunnelFixtureScope { + private cleanups: Array<() => void | Promise> = []; + after(cleanup: () => void | Promise): void { this.cleanups.push(cleanup); } + own(socket: T): T { + socket.on("error", () => {}); + this.after(() => { socket.destroy(); }); + return socket; + } + later(callback: () => void, ms: number): void { + const timer = setTimeout(callback, ms); + this.after(() => clearTimeout(timer)); + } + async close(): Promise { + const failures: unknown[] = []; + for (const cleanup of this.cleanups.splice(0).reverse()) { + try { await cleanup(); } catch (error) { failures.push(error); } + } + if (failures.length) throw new AggregateError(failures, "tunnel fixture cleanup failed"); + } +} + +export const target = "fixture.invalid:443"; +export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +export const digest = (data: Uint8Array) => createHash("sha256").update(data).digest("hex"); + +export async function until(predicate: () => boolean): Promise { + const deadline = performance.now() + 2_000; + while (!predicate() && performance.now() < deadline) await wait(5); + assert.ok(predicate(), "fixture condition did not settle"); +} + +export async function listen(scope: TunnelFixtureScope, server: Server, sockets = new Set()): Promise { + server.on("connection", socket => { + sockets.add(socket); + socket.on("error", () => {}); + socket.once("close", () => sockets.delete(socket)); + }); + scope.after(async () => { + for (const socket of sockets) socket.destroy(); + if (server.listening) await new Promise(resolve => server.close(() => resolve())); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + return (server.address() as AddressInfo).port; +} + +export async function deadProxy(kind: OpaqueProxyRoute["kind"] = "http"): Promise { + // An OS-assigned local port, closed before the candidate is used. No destination DNS lookup. + const server = net.createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + await new Promise(resolve => server.close(() => resolve())); + return { kind, host: "127.0.0.1", port } as OpaqueProxyRoute; +} + +export async function readBytes(socket: Socket, count: number): Promise { + return new Promise((resolve, reject) => { + let data = Buffer.alloc(0); + const done = () => { + socket.pause(); + socket.removeListener("data", onData); + socket.removeListener("end", onEnd); + socket.removeListener("error", onError); + }; + const onData = (chunk: Buffer) => { + data = Buffer.concat([data, chunk]); + if (data.length < count) return; + done(); + if (data.length > count) socket.unshift(data.subarray(count)); + resolve(data.subarray(0, count)); + }; + const onEnd = () => { done(); reject(new Error(`early end: ${data.length}/${count}`)); }; + const onError = (error: Error) => { done(); reject(error); }; + socket.on("data", onData); + socket.once("end", onEnd); + socket.once("error", onError); + socket.resume(); + }); +} + +export async function echo(socket: Socket, payload: Buffer, slowRead = false): Promise { + const chunks: Buffer[] = []; + const ended = once(socket, "end"); + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + if (slowRead) socket.pause(); + socket.end(payload); + if (slowRead) { await wait(35); socket.resume(); } + await ended; + const received = Buffer.concat(chunks); + assert.equal(received.length, payload.length); + assert.equal(digest(received), digest(payload)); +} + +export type HttpFixtureMode = "echo" | "hang" | "407" | "403" | "malformed" | "oversize" + | "bad-header" | "partial-refusal" | "partial-timeout" | "banner" | "drop" | "slow"; + +export async function httpProxy(scope: TunnelFixtureScope, mode: HttpFixtureMode = "echo") { + const sockets = new Set(); + const seen = { calls: 0, bytes: 0, payloads: [] as Buffer[], authorities: [] as string[] }; + const server = net.createServer({ allowHalfOpen: true }, socket => { + let pending = Buffer.alloc(0), writes = 0, ended = false; + socket.once("end", () => { ended = true; if (!writes) socket.end(); }); + const handshake = (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const end = pending.indexOf("\r\n\r\n"); + if (end < 0) return; + socket.removeListener("data", handshake); + seen.calls++; + const headers = pending.subarray(0, end).toString(); + assert.match(headers, /^CONNECT fixture\.invalid:443 HTTP\/1\.1\r\nHost: fixture\.invalid:443$/); + seen.authorities.push(headers.split("\r\n")[0]); + if (mode === "hang") return; + if (mode === "407" || mode === "403") { socket.end(`HTTP/1.1 ${mode} Refused\r\nContent-Length: 0\r\n\r\n`); return; } + if (mode === "malformed") { socket.end("NOT HTTP\r\n\r\n"); return; } + if (mode === "oversize") { socket.write("HTTP/1.1 200 OK\r\nX: " + "x".repeat(9000)); return; } + if (mode === "bad-header") { socket.end("HTTP/1.1 200 OK\r\nInvalidHeader\r\n\r\n"); return; } + if (mode === "partial-refusal") { socket.end("HTTP/1.1 407 Refused\r\nIncomplete"); return; } + if (mode === "partial-timeout") { socket.write("HTTP/1.1 4"); return; } + // Coalesce early opaque bytes with the CONNECT response to exercise unshift ownership. + socket.write("HTTP/1.1 200 Connection Established\r\n\r\n" + (mode === "banner" ? "opaque-banner" : "")); + const payload = (data: Buffer) => { + seen.bytes += data.length; + seen.payloads.push(data); + if (mode === "drop") { socket.destroy(); return; } + if (mode === "slow") { + writes++; + socket.pause(); + scope.later(() => { + writes--; + if (socket.destroyed) return; + socket.write(data); + if (ended && !writes) socket.end(); else socket.resume(); + }, 3); + } else socket.write(data); + }; + socket.on("data", payload); + const head = pending.subarray(end + 4); + if (head.length) payload(head); + }; + socket.on("data", handshake); + }); + const port = await listen(scope, server, sockets); + return { candidate: { host: "127.0.0.1", port } satisfies OpaqueProxyRoute, seen, sockets }; +} diff --git a/tests/lib/opaque-proxy-tunnel-protocol.test.ts b/tests/lib/opaque-proxy-tunnel-protocol.test.ts new file mode 100644 index 00000000000..e73eb834646 --- /dev/null +++ b/tests/lib/opaque-proxy-tunnel-protocol.test.ts @@ -0,0 +1,128 @@ +import { afterEach, test } from "bun:test"; +import assert from "node:assert/strict"; +import { dialTunnel, type OpaqueProxyRoute, type OpaqueTunnelOptions } from "../../src/lib/opaque-proxy-tunnel"; +import { deadProxy, digest, echo, readBytes, TunnelFixtureScope, until, wait } from "../helpers/opaque-proxy-tunnel"; +import { ca, credentials, origin, protocolProxy, proxyKinds, securedClient } from "../helpers/opaque-proxy-tunnel-protocol"; + +const scope = new TunnelFixtureScope(); +afterEach(() => scope.close()); +const dial = (authority: string, route: OpaqueProxyRoute, options: OpaqueTunnelOptions = {}) => dialTunnel(authority, route, options); + +for (const kind of proxyKinds) { + test(`${kind}: real authenticated tunnel carries independently verified opaque target TLS`, async () => { + const end = await origin(scope); const p = await protocolProxy(scope, kind, { destination: end, fragmented: true }); + const raw = await dial(end.authority, p.candidate); + const secured = await securedClient(scope, raw); const payload = Buffer.from('origin-only plaintext marker\0\xff'); await echo(secured, payload); + assert.equal(p.seen.auth, 1); assert.deepEqual(p.seen.targets, [{ host: 'origin.fixture.invalid', port: end.port }]); + assert.deepEqual(Buffer.concat(end.received), payload); + assert.equal(Buffer.concat(p.seen.opaque).includes(payload), false, 'proxy must see encrypted target data'); + assert.equal(Buffer.concat(end.received).includes(Buffer.from('Proxy-Authorization')), false); + }, 7000); + test(`${kind}: explicit dead route reports failure`, async () => { + await assert.rejects(dial('origin.fixture.invalid:443', await deadProxy(kind)), error => ['TRANSPORT', 'PROXY_TLS'].includes((error as { code: string }).code)); + }, 5000); + test(`${kind}: authentication refusal is terminal and credential-safe`, async () => { + const denied = await protocolProxy(scope, kind, { mode: 'refuse' }); + await assert.rejects(dial('origin.fixture.invalid:443', denied.candidate), error => { + assert.ok(["REFUSED", "SOCKS_REFUSED"].includes((error as { code: string }).code)); + const diagnostic = String(error) + JSON.stringify(error); + assert.equal(diagnostic.includes(credentials.username), false); + assert.equal(diagnostic.includes(credentials.password), false); + assert.equal(diagnostic.includes(Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")), false); + return true; + }); + await until(() => denied.sockets.size === 0); + }, 5000); + test(`${kind}: no-response timeout closes the only route`, async () => { + const hung = await protocolProxy(scope, kind, { mode: 'hang' }); + await assert.rejects(dial('origin.fixture.invalid:443', hung.candidate, { timeoutMs: 200 }), { code: 'TIMEOUT' }); + assert.equal(hung.seen.connections, 1); await until(() => hung.sockets.size === 0); + }, 5000); + test(`${kind}: partial response timeout is terminal`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'partial' }); + await assert.rejects(dial('origin.fixture.invalid:443', p.candidate, { timeoutMs: 150 }), { code: 'TIMEOUT' }); + await until(() => p.sockets.size === 0); + }, 5000); + test(`${kind}: caller cancellation closes the selected route`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'hang' }), controller = new AbortController(); + const result = dial('origin.fixture.invalid:443', p.candidate, { signal: controller.signal }); + const assertion = assert.rejects(result, { code: 'ABORTED' }); await until(() => p.seen.handshakes === 1); controller.abort(); await assertion; + await until(() => p.sockets.size === 0); + }, 5000); + test(`${kind}: target TLS 2 MiB backpressure and half-close preserve bytes`, async () => { + const end = await origin(scope, { slow: true }), p = await protocolProxy(scope, kind, { destination: end }); + const socket = await dial(end.authority, p.candidate); const secured = await securedClient(scope, socket); + const payload = Buffer.alloc(2 * 1024 * 1024); for (let i = 0; i < payload.length; i++) payload[i] = i % 251; + await echo(secured, payload, true); assert.equal(digest(Buffer.concat(end.received)), digest(payload)); + }, 12000); + test(`${kind}: failure after opaque target TLS and application bytes never replays`, async () => { + const end = await origin(scope, { drop: true }), p = await protocolProxy(scope, kind, { destination: end }); + const socket = await dial(end.authority, p.candidate); const secured = await securedClient(scope, socket); + const closed = new Promise(resolve => { secured.once('end', resolve); secured.once('close', resolve); secured.once('error', () => resolve()); }); + const payload = Buffer.from('single-committed-operation'); secured.write(payload); secured.resume(); await closed; secured.destroy(); + assert.deepEqual(Buffer.concat(end.received), payload); assert.equal(end.calls(), 1); + }, 5000); +} + +for (const mode of ['untrusted', 'wrong-name']) { + test(`HTTPS proxy ${mode} certificate is rejected`, async () => { + const p = await protocolProxy(scope, 'https', { mode: 'hang', wrongIdentity: mode === 'wrong-name' }); + assert.equal(p.candidate.kind, 'https'); + if (p.candidate.kind !== 'https') throw new Error('HTTPS fixture required'); + const bad = { ...p.candidate, ...(mode === 'untrusted' ? { proxyCa: undefined } : {}) }; + await assert.rejects(dial('origin.fixture.invalid:443', bad), error => (error as { code: string }).code === 'PROXY_TLS'); + await until(() => p.sockets.size === 0); + }, 5000); +} +test('target TLS certificate still rejects a wrong hostname through a verified HTTPS proxy', async () => { + const end = await origin(scope), p = await protocolProxy(scope, 'https', { destination: end }); + const socket = await dial(end.authority, p.candidate); + await assert.rejects(securedClient(scope, socket, 'wrong.fixture.invalid'), /certificate|hostname|altnames/i); +}, 5000); +for (const kind of ['socks5', 'socks5h'] as const) { + test(`${kind}: early opaque bytes beyond 64 KiB survive CONNECT response coalescing`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'early-large' }); + const socket = await dial('origin.fixture.invalid:443', p.candidate); scope.own(socket); + assert.deepEqual(await readBytes(socket, 192 * 1024), Buffer.alloc(192 * 1024, 0xa5)); + }, 5000); + test(`${kind}: explicit SOCKS policy refusal is reported`, async () => { + const p = await protocolProxy(scope, kind, { mode: 'policy' }); + await assert.rejects(dial('origin.fixture.invalid:443', p.candidate), { code: 'SOCKS_REFUSED' }); + + }, 5000); +} +test('stalled HTTPS proxy TLS handshake is bounded, terminal, and cancellable', async () => { + const plain = await protocolProxy(scope, 'http', { mode: 'hang' }); + const candidate = { kind: 'https' as const, host: '127.0.0.1', port: plain.candidate.port, proxyCa: ca }; + await assert.rejects(dial('origin.fixture.invalid:443', candidate, { timeoutMs: 60 }), error => (error as { code: string }).code === 'TIMEOUT'); + await until(() => plain.sockets.size === 0); + const abort = new AbortController(), pending = dial('origin.fixture.invalid:443', candidate, { signal: abort.signal }); + const assertion = assert.rejects(pending, { code: 'ABORTED' }); await until(() => plain.seen.connections === 2); abort.abort(); await assertion; await until(() => plain.sockets.size === 0); +}, 5000); +test('configuration rejects unsupported protocols and invalid credential sizes before dialing', async () => { + const route = { host: '127.0.0.1', port: 1 }; + await assert.rejects(dial('origin.fixture.invalid:443', { ...route, kind: 'direct' } as unknown as OpaqueProxyRoute), { code: 'CONFIG' }); + await assert.rejects(dial('origin.fixture.invalid:443', { ...route, kind: 'socks5', username: 'x'.repeat(256), password: 'p' }), { code: 'CONFIG' }); + await assert.rejects(dial('origin.fixture.invalid:443', { ...route, kind: 'socks5h', username: 'u', password: 'x'.repeat(256) }), { code: 'CONFIG' }); + await assert.rejects(dial('origin.fixture.invalid:443', route, { timeoutMs: 0 }), { code: 'CONFIG' }); +}); + +for (const kind of proxyKinds) { + test(`${kind}: after establishment the caller owns cancellation and the socket`, async () => { + const end = await origin(scope), proxy = await protocolProxy(scope, kind, { destination: end }); + const controller = new AbortController(); + const socket = await dial(end.authority, proxy.candidate, { + signal: controller.signal, timeoutMs: 200, + }); + scope.own(socket); + assert.equal(socket.isPaused(), true); + controller.abort(); + // The already-cleared establishment deadline must not destroy caller-owned I/O. + await wait(250); + assert.equal(socket.destroyed, false); + const secured = await securedClient(scope, socket); + const payload = Buffer.from("caller-owned-after-establishment"); + await echo(secured, payload); + assert.deepEqual(Buffer.concat(end.received), payload); + }, 5_000); +} diff --git a/tests/lib/opaque-proxy-tunnel.test.ts b/tests/lib/opaque-proxy-tunnel.test.ts new file mode 100644 index 00000000000..ed8dd10684a --- /dev/null +++ b/tests/lib/opaque-proxy-tunnel.test.ts @@ -0,0 +1,102 @@ +import { afterEach, test } from "bun:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { once } from "node:events"; +import { dialTunnel, type OpaqueProxyRoute } from "../../src/lib/opaque-proxy-tunnel"; +import { deadProxy, digest, echo, httpProxy, listen, readBytes, target, TunnelFixtureScope, until } from "../helpers/opaque-proxy-tunnel"; + +const scope = new TunnelFixtureScope(); +afterEach(() => scope.close()); + +test("single explicit proxy carries opaque bytes", async () => { + const live = await httpProxy(scope), socket = scope.own(await dialTunnel(target, live.candidate)); + const expected = Buffer.from([0, 255, 1, 127, 13, 10]); + const reply = readBytes(socket, expected.length); + socket.write(expected); + assert.deepEqual(await reply, expected); + assert.equal(live.seen.calls, 1); +}); + +test("failed proxy never opens a direct destination connection", async () => { + const dead = await deadProxy(); + let directCalls = 0; + const destination = net.createServer(socket => { directCalls++; socket.destroy(); }); + const port = await listen(scope, destination); + await assert.rejects(dialTunnel(`127.0.0.1:${port}`, dead), { code: "TRANSPORT" }); + assert.equal(directCalls, 0); +}); + +for (const mode of ["407", "403", "malformed", "oversize", "bad-header", "partial-refusal", "partial-timeout"] as const) { + test(`${mode} closes the selected proxy and reports failure`, async () => { + const proxy = await httpProxy(scope, mode); + await assert.rejects(dialTunnel(target, proxy.candidate, { timeoutMs: 150, maxHeaderBytes: 8192 }), error => { + const failure = error as { code: string; status?: number }; + const expected = mode === "407" || mode === "403" || mode === "partial-refusal" ? "REFUSED" : mode === "partial-timeout" ? "TIMEOUT" : "PROTOCOL"; + assert.equal(failure.code, expected); + if (mode === "407" || mode === "403") assert.equal(failure.status, Number(mode)); + return true; + }); + assert.equal(proxy.seen.calls, 1); + await until(() => proxy.sockets.size === 0); + }); +} + +test("establishment timeout destroys the hanging socket", async () => { + const hang = await httpProxy(scope, "hang"); + await assert.rejects(dialTunnel(target, hang.candidate, { timeoutMs: 150 }), { code: "TIMEOUT" }); + assert.equal(hang.seen.calls, 1); + await until(() => hang.sockets.size === 0); +}); + +test("caller cancellation closes establishment", async () => { + const hang = await httpProxy(scope, "hang"), controller = new AbortController(); + const pending = dialTunnel(target, hang.candidate, { signal: controller.signal, timeoutMs: 2000 }); + const check = assert.rejects(pending, { code: "ABORTED" }); + await until(() => hang.seen.calls === 1); + controller.abort(); await check; + await until(() => hang.sockets.size === 0); +}); + +test("already-cancelled request opens no proxy connection", async () => { + const proxy = await httpProxy(scope), controller = new AbortController(); + controller.abort(); + await assert.rejects(dialTunnel(target, proxy.candidate, { signal: controller.signal }), { code: "ABORTED" }); + assert.equal(proxy.seen.calls, 0); + assert.equal(proxy.sockets.size, 0); +}); + +test("early opaque bytes coalesced with CONNECT headers are preserved", async () => { + const proxy = await httpProxy(scope, "banner"), socket = scope.own(await dialTunnel(target, proxy.candidate)); + const payload = Buffer.from("early-client-bytes"); + const received = readBytes(socket, 13 + payload.length); + socket.write(payload); + assert.deepEqual(await received, Buffer.concat([Buffer.from("opaque-banner"), payload])); + assert.deepEqual(Buffer.concat(proxy.seen.payloads), payload); +}); + +test("caller half-close preserves a 4 MiB echoed response under backpressure", async () => { + const proxy = await httpProxy(scope, "slow"), socket = scope.own(await dialTunnel(target, proxy.candidate)); + const payload = Buffer.alloc(4 * 1024 * 1024); + for (let i = 0; i < payload.length; i++) payload[i] = i % 251; + await echo(socket, payload, true); + assert.equal(proxy.seen.bytes, payload.length); + assert.equal(digest(Buffer.concat(proxy.seen.payloads)), digest(payload)); +}, 12000); + +test("failure after establishment and application bytes never reconnects or replays", async () => { + const drop = await httpProxy(scope, "drop"), socket = scope.own(await dialTunnel(target, drop.candidate)); + const ended = once(socket, "end"); + socket.write("unique-turn-payload"); socket.resume(); await ended; socket.end(); + assert.equal(drop.seen.bytes, 19); + assert.equal(drop.seen.calls, 1); +}); + +test("configuration requires one route and a well-formed authority", async () => { + const proxy = await httpProxy(scope); + for (const authority of ["target", "target:0", "target:65536", "target:443\r\nInjected: yes"]) { + await assert.rejects(dialTunnel(authority, proxy.candidate), { code: "TARGET" }); + } + await assert.rejects(dialTunnel(target, [] as unknown as OpaqueProxyRoute), { code: "CONFIG" }); + await assert.rejects(dialTunnel(target, undefined as unknown as OpaqueProxyRoute), { code: "CONFIG" }); + assert.equal(proxy.seen.calls, 0); +}); diff --git a/tests/lib/optional-desktop-upstream.test.ts b/tests/lib/optional-desktop-upstream.test.ts index 820ceea9c67..a4fa7c321f5 100644 --- a/tests/lib/optional-desktop-upstream.test.ts +++ b/tests/lib/optional-desktop-upstream.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; +import assert from "node:assert/strict"; import { createServer as createHttps } from "node:https"; -import { createServer as createTcp } from "node:net"; +import { createServer as createTcp, type Socket } from "node:net"; import { dialDesktopUpstream } from "../../src/lib/desktop-upstream-tunnel"; import { desktopProxyFor } from "../../src/lib/desktop-proxy-route"; import { createCertificateAuthority, issueServerLeaf } from "../../src/claude/intercept/local-ca"; @@ -25,7 +26,7 @@ test("desktop HTTP and WS choose explicit NO_PROXY, supported proxies, or a clos expect(() => desktopProxyFor(url, { HTTPS_PROXY: "http://127.0.0.1:4444/path" })).toThrow("proxy_invalid"); }); -for (const kind of ["http", "https", "socks5"] as const) test(`verified TLS through authenticated ${kind} proxy preserves raw request and response`, async () => { +for (const kind of ["http", "https", "socks5", "socks5h"] as const) test(`verified TLS through authenticated ${kind} proxy preserves raw request and response`, async () => { const ca = createCertificateAuthority({ commonName: "egress-fixture", validityDays: 1 }); const leaf = issueServerLeaf(ca, "egress-fixture", ["chatgpt.com"]), proxyLeaf = issueServerLeaf(ca, "egress-fixture", ["localhost"]); let cookie: string | undefined, leaked: string | undefined; @@ -33,17 +34,19 @@ for (const kind of ["http", "https", "socks5"] as const) test(`verified TLS thro cookie = req.headers.cookie; leaked = req.headers["proxy-authorization"] as string | undefined; res.end("fixture-response"); }); - const origin = await listenFixture(upstream), proxy = await forwardProxy(kind, origin.port, proxyLeaf); + const transport = kind === "socks5h" ? "socks5" : kind; + const origin = await listenFixture(upstream), proxy = await forwardProxy(transport, origin.port, proxyLeaf); let socket: Awaited> | undefined; try { - socket = await dialDesktopUpstream({ proxy: proxy.url, ca: ca.certPem, proxyCa: ca.certPem, target: { host: "chatgpt.com", port: 443 } }); + socket = await dialDesktopUpstream({ proxy: proxy.url.replace(/^socks5:/, `${kind}:`), ca: ca.certPem, proxyCa: ca.certPem, target: { host: "chatgpt.com", port: 443 } }); + assert.equal(socket.authorized, true); const response = new Promise((resolve, reject) => { let text = ""; socket!.on("data", bytes => { text += bytes.toString(); }); socket!.once("end", () => resolve(text)); socket!.once("error", reject); }); socket.write("GET /fixture HTTP/1.1\r\nHost: chatgpt.com\r\nCookie: fixture=session\r\nConnection: close\r\n\r\n"); expect(await response).toContain("fixture-response"); expect(cookie).toBe("fixture=session"); expect(leaked).toBeUndefined(); expect(proxy.seen).toHaveLength(1); expect(proxy.seen[0]!.authority).toBe("chatgpt.com:443"); - expect(proxy.seen[0]!.auth).toBe(kind === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64")); + expect(proxy.seen[0]!.auth).toBe(transport === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64")); } finally { socket?.destroy(); await proxy.close(); await origin.close(); } }, 15000); @@ -122,3 +125,94 @@ for (const failure of ["eof", "oversized", "timeout", "abort"] as const) test(`p await closedEvent; } finally { await proxy.close(); } }, 3000); + +/** Read exactly one fixture HTTP response while keeping its verified TLS connection alive. */ +function fixtureRequest(socket: Socket, path: string): Promise { + return new Promise((resolve, reject) => { + let bytes: Buffer = Buffer.alloc(0); + const cleanup = () => { + clearTimeout(timer); socket.off("data", receive); socket.off("error", fail); + socket.off("end", ended); socket.off("close", ended); + }; + const fail = (error: Error) => { cleanup(); reject(error); }; + const ended = () => fail(new Error("Fixture response closed before Content-Length bytes arrived")); + const receive = (chunk: Buffer) => { + try { + bytes = Buffer.concat([bytes, chunk]); + assert.ok(bytes.length <= 65536, "fixture response is bounded"); + const end = bytes.indexOf("\r\n\r\n"); + if (end < 0) return; + const head = bytes.subarray(0, end).toString("latin1"); + assert.match(head, /^HTTP\/1\.1 200 /); + const length = /\r\ncontent-length: ([0-9]+)(?:\r\n|$)/i.exec(head); + assert.ok(length, "fixture response declares Content-Length"); + const expected = end + 4 + Number(length[1]); + assert.ok(bytes.length <= expected, "one request receives exactly one response"); + if (bytes.length === expected) { cleanup(); resolve(bytes.subarray(end + 4).toString("utf8")); } + } catch (error) { fail(error as Error); } + }; + const timer = setTimeout(() => fail(new Error("Fixture response deadline exceeded")), 2000); + socket.on("data", receive); socket.once("error", fail); socket.once("end", ended); socket.once("close", ended); + socket.write(`GET ${path} HTTP/1.1\r\nHost: chatgpt.com\r\nCookie: fixture=session\r\nConnection: keep-alive\r\n\r\n`); + }); +} + +for (const kind of ["socks5", "socks5h"] as const) for (const credentials of [ + { username: "fixture:colon", password: "secret" }, + { username: "", password: "secret" }, + { username: "fixture", password: "" }, +]) test(`desktop ${kind} preserves SOCKS credentials ${JSON.stringify(credentials)}`, async () => { + const ca = createCertificateAuthority({ commonName: "socks-credentials-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "socks-credentials-fixture", ["chatgpt.com"]); + const origin = await listenFixture(createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (_req, res) => { + const body = "authenticated-fixture"; res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); + })); + const proxy = await forwardProxy("socks5", origin.port, leaf, credentials); + let socket: Awaited> | undefined; + try { + socket = await dialDesktopUpstream({ proxy: proxy.url.replace(/^socks5:/, `${kind}:`), ca: ca.certPem }); + assert.equal(socket.authorized, true); + assert.equal(await fixtureRequest(socket, "/credentials"), "authenticated-fixture"); + assert.deepEqual(proxy.seen, [{ authority: "chatgpt.com:443", auth: `${credentials.username}:${credentials.password}` }]); + } finally { socket?.destroy(); await proxy.close(); await origin.close(); } +}, 10000); + +for (const kind of ["http", "https", "socks5", "socks5h"] as const) test(`desktop ${kind} consumer fully closes after upstream EOF without caller cleanup`, async () => { + const ca = createCertificateAuthority({ commonName: "desktop-eof-fixture", validityDays: 1 }); + const leaf = issueServerLeaf(ca, "desktop-eof-fixture", ["chatgpt.com"]); + const proxyLeaf = issueServerLeaf(ca, "desktop-eof-fixture", ["localhost"]); + let peer: Socket | undefined, cookie: string | undefined, proxyAuth: string | string[] | undefined; + const origin = await listenFixture(createHttps({ cert: leaf.certPem, key: leaf.keyPem }, (req, res) => { + peer = req.socket; cookie = req.headers.cookie; proxyAuth = req.headers["proxy-authorization"]; + const body = "fixture-before-eof"; res.setHeader("Content-Length", Buffer.byteLength(body)); res.end(body); + })); + const transport = kind === "socks5h" ? "socks5" : kind; + const proxy = await forwardProxy(transport, origin.port, proxyLeaf); + let socket: Awaited> | undefined; + let timer: ReturnType | undefined; + try { + // Keep the real destination and certificate identity; only the fixture proxy redirects to loopback. + socket = await dialDesktopUpstream({ proxy: proxy.url.replace(/^socks5:/, `${kind}:`), ca: ca.certPem, proxyCa: ca.certPem }); + assert.equal(socket.authorized, true); + assert.equal(socket.allowHalfOpen, false, "desktop consumer retains automatic writable-side closure"); + assert.equal(await fixtureRequest(socket, "/peer-eof"), "fixture-before-eof"); + assert.equal(cookie, "fixture=session"); assert.equal(proxyAuth, undefined); + assert.deepEqual(proxy.seen, [{ authority: "chatgpt.com:443", + auth: transport === "socks5" ? "fixture:secret" : "Basic " + Buffer.from("fixture:secret").toString("base64") }]); + assert.ok(peer); assert.equal(peer.destroyed, false); + assert.equal(socket.readableEnded, false); assert.equal(socket.writableEnded, false); assert.equal(socket.destroyed, false); + + let consumerError: Error | undefined; + socket.once("error", error => { consumerError = error; }); + const closed = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error("Consumer did not fully close after upstream EOF")), 2000); + socket!.once("close", hadError => { clearTimeout(timer); resolve(hadError); }); + }); + // Only the upstream initiates termination. No consumer end/destroy or fixture cleanup occurs before close. + peer.end(); + assert.equal(await closed, false); + assert.equal(consumerError, undefined); + assert.equal(socket.readableEnded, true); assert.equal(socket.writableEnded, true); assert.equal(socket.destroyed, true); + assert.equal(proxy.seen.length, 1, "EOF never redials or replays the request"); + } finally { clearTimeout(timer); socket?.destroy(); await proxy.close(); await origin.close(); } +}, 10000);