From 9ea5ad93df00f33bd356d6b17581084087325784 Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 12:33:34 -0500 Subject: [PATCH 1/2] fix(main): let Stop and timeouts end a pending DNS lookup - The desktop HTTP/SSE and WebSocket transports awaited the destination lookup without their abort signal, so a resolver that never answered delayed Stop and the request deadline indefinitely. - resolveGuardedNetworkTarget accepts the caller's signal and settles with the caller's cancel or timeout, never a policy rejection. HTTP passes its request controller; WebSocket bounds the lookup with the same Stop and deadline it already applies to the connection. - Build WebSocket handshake headers on a null-prototype object so a header named __proto__ is sent instead of silently dropped. Changelog: none --- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 3 +++ src/main/httpProxy.ts | 3 ++- src/main/httpWebSocket.ts | 21 ++++++++++++++-- src/main/networkTargetPolicy.ts | 29 ++++++++++++++++++---- tests/main/httpProxy.test.ts | 11 +++++++++ tests/main/httpWebSocket.test.ts | 41 ++++++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4543f582f..aedc86121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format follows Keep a Changelog and groups changes by release. ### Fixed - Lua `string.format` with a zero `%g` precision, such as `%.0g`, now formats like standard Lua instead of failing the run with a meaningless error. - `lingua capsule verify` and the regression-suite export name the engine mismatch for Python, Go and web Ruby recordings. A real recording with per-line results or a failed run used to report a generic incomplete baseline instead. +- Desktop HTTP, SSE and WebSocket requests honor Stop and their timeout while the host name is still resolving, instead of waiting on a DNS lookup that never answers. A WebSocket header named `__proto__` is now sent like any other header. ## [1.6.0] — 2026-10-13 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60a6b53d8..9cf0597d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1125,6 +1125,9 @@ HTTP/SSE and WebSocket. It owns URL parsing, caller-selected scheme validation, private-address classification and validation of all lookup results. It imports only Node's address classifier, creates no transport, and keeps no DNS cache or mutable singleton. Lookup evidence is returned unchanged for socket pinning. +The lookup takes the caller's abort signal, so Stop and the request deadline +settle a resolution that never answers; an aborted lookup is reported as the +caller's cancel or timeout, never as a policy rejection. `httpProxy.ts` owns HTTP redirects, credentials, undici dispatchers and body/SSE limits; `httpWebSocket.ts` owns handshake, messages, redirects-off and socket diff --git a/src/main/httpProxy.ts b/src/main/httpProxy.ts index d5861bb38..e968c7a16 100644 --- a/src/main/httpProxy.ts +++ b/src/main/httpProxy.ts @@ -479,7 +479,8 @@ export async function executeHttpProxyRequest( currentUrl, HTTP_PROTOCOLS, allowPrivateHosts, - lookupImpl + lookupImpl, + controller.signal ); let hopDispatcher: Agent | null = null; if (fetchImpl) { diff --git a/src/main/httpWebSocket.ts b/src/main/httpWebSocket.ts index 8caa6d271..8eabaecf7 100644 --- a/src/main/httpWebSocket.ts +++ b/src/main/httpWebSocket.ts @@ -67,7 +67,8 @@ function failure( } function toHeaderRecord(request: HttpRequestV1): Record { - const headers: Record = {}; + // No prototype: a header named `__proto__` must stay a header. + const headers: Record = Object.create(null); for (const entry of composeRequestHeaders(request)) { if (!entry.name.trim()) continue; if (/\r|\n/u.test(entry.name) || /\r|\n/u.test(entry.value)) continue; @@ -97,21 +98,37 @@ export async function executeWebSocketProxyRequest( const lookupImpl: LookupImpl = options.lookupImpl ?? ((hostname) => dnsLookup(hostname, { all: true })); + // The connection below owns its own Stop and timeout; the lookup needs both too. + const lookupController = new AbortController(); + const lookupTimer = setTimeout(() => lookupController.abort('timeout'), timeoutMs); + const abortLookup = (): void => lookupController.abort('cancelled'); + if (options.signal?.aborted) abortLookup(); + else options.signal?.addEventListener('abort', abortLookup, { once: true }); let target; try { target = await resolveGuardedNetworkTarget( request.url, WEBSOCKET_PROTOCOLS, allowPrivateHosts, - lookupImpl + lookupImpl, + lookupController.signal ); } catch (error) { + if (lookupController.signal.reason === 'timeout') { + return failure(request, 'timeout', 'Request timed out', startedAt); + } + if (lookupController.signal.aborted) { + return failure(request, 'network-error', 'Request cancelled', startedAt); + } return failure( request, 'network-error', error instanceof Error ? error.message : String(error), startedAt ); + } finally { + clearTimeout(lookupTimer); + options.signal?.removeEventListener('abort', abortLookup); } const openingMessage = request.body?.content ?? ''; diff --git a/src/main/networkTargetPolicy.ts b/src/main/networkTargetPolicy.ts index bab6156b3..8a91314f8 100644 --- a/src/main/networkTargetPolicy.ts +++ b/src/main/networkTargetPolicy.ts @@ -196,7 +196,8 @@ export function isPrivateAddress(ip: string): boolean { async function assertHostAllowed( rawHostname: string, allowPrivateHosts: boolean, - lookupImpl: LookupImpl + lookupImpl: LookupImpl, + signal?: AbortSignal ): Promise { // WHATWG `URL` keeps the square brackets on an IPv6 host (`[::1]`), and @@ -232,8 +233,10 @@ async function assertHostAllowed( let addresses: LookupAddress[]; try { - addresses = await lookupImpl(hostname); + addresses = await abortable(lookupImpl(hostname), signal); } catch (err) { + // Cancel and timeout belong to the caller, not to the destination policy. + if (signal?.aborted) throw abortError(signal); const message = err instanceof Error ? err.message : String(err); throw new SsrfBlockedError(`DNS resolution failed for ${hostname}: ${message}`); } @@ -250,6 +253,22 @@ async function assertHostAllowed( return addresses; } +function abortError(signal: AbortSignal): Error { + const reason: unknown = signal.reason; + return reason instanceof Error ? reason : new Error(`Request aborted: ${String(reason)}`); +} + +/** Settle with `signal`'s abort even when a DNS lookup never answers. */ +function abortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(abortError(signal)); + return new Promise((resolve, reject) => { + const onAbort = (): void => reject(abortError(signal)); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort)); + }); +} + export interface GuardedNetworkTarget { readonly url: URL; readonly addresses: ReadonlyArray; @@ -260,7 +279,8 @@ export async function resolveGuardedNetworkTarget( rawUrl: string, allowedProtocols: ReadonlySet, allowPrivateHosts: boolean, - lookupImpl: LookupImpl + lookupImpl: LookupImpl, + signal?: AbortSignal ): Promise { let url: URL; try { @@ -274,7 +294,8 @@ export async function resolveGuardedNetworkTarget( const addresses = await assertHostAllowed( url.hostname, allowPrivateHosts, - lookupImpl + lookupImpl, + signal ); return { url, addresses }; } diff --git a/tests/main/httpProxy.test.ts b/tests/main/httpProxy.test.ts index 445ee1aa7..50760d6ec 100644 --- a/tests/main/httpProxy.test.ts +++ b/tests/main/httpProxy.test.ts @@ -254,6 +254,17 @@ describe('executeHttpProxyRequest — failure branches', () => { expect(res.errorMessage).toMatch(/timed out/i); }); + it('times out a DNS lookup that never answers instead of waiting on it', async () => { + const fetchImpl = vi.fn(mockFetch({ status: 200 })); + const hangingLookup: LookupImpl = () => new Promise(() => {}); + const res = await executeHttpProxyRequest(makeRequest({ timeoutMs: 10 }), { + fetchImpl, + lookupImpl: hangingLookup, + }); + expect(res.kind).toBe('timeout'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('surfaces a generic fetch failure as network-error', async () => { const fetchImpl = (async () => { throw new TypeError('Failed to fetch'); diff --git a/tests/main/httpWebSocket.test.ts b/tests/main/httpWebSocket.test.ts index 08b0fa2e7..b1b2c8fc2 100644 --- a/tests/main/httpWebSocket.test.ts +++ b/tests/main/httpWebSocket.test.ts @@ -91,6 +91,47 @@ describe('desktop WebSocket proxy', () => { expect(progress).toContain('echo:hello'); }); + it('honors Stop and the deadline while DNS is still resolving', async () => { + const hangingLookup = () => new Promise(() => {}); + const controller = new AbortController(); + const cancelled = executeWebSocketProxyRequest(request('wss://example.com/socket'), { + lookupImpl: hangingLookup, + signal: controller.signal, + }); + controller.abort(); + await expect(cancelled).resolves.toMatchObject({ + kind: 'network-error', + errorMessage: 'Request cancelled', + }); + + const timedOut = await executeWebSocketProxyRequest( + { ...request('wss://example.com/socket'), timeoutMs: 10 }, + { lookupImpl: hangingLookup } + ); + expect(timedOut).toMatchObject({ kind: 'timeout', errorMessage: 'Request timed out' }); + }); + + it('sends a header named __proto__ as an ordinary header', async () => { + const server = createServer(); + const webSocketServer = new WebSocketServer({ server }); + let received: string | undefined; + webSocketServer.on('connection', (socket, upgrade) => { + const raw = upgrade.rawHeaders; + received = raw[raw.findIndex((name) => name.toLowerCase() === '__proto__') + 1]; + socket.close(1000, 'done'); + }); + const port = await listen(server); + await executeWebSocketProxyRequest( + { + ...request(`ws://127.0.0.1:${port}/socket`), + headers: [{ id: 'h1', name: '__proto__', value: 'kept', enabled: true }], + }, + { allowPrivateHosts: true } + ); + webSocketServer.close(); + expect(received).toBe('kept'); + }); + it('rejects handshake redirects instead of following them', async () => { const server = createServer((_request, response) => { response.writeHead(302, { location: 'ws://127.0.0.1:9/private' }); From 64eb81da007a54badffcdbce17eec8a4909cbcad Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 12:41:45 -0500 Subject: [PATCH 2/2] test(main): drop the stray id from the __proto__ header fixture - HttpRequestHeader has no id field; typecheck:tests rejected the literal. Changelog: none --- tests/main/httpWebSocket.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/main/httpWebSocket.test.ts b/tests/main/httpWebSocket.test.ts index b1b2c8fc2..f7f7cd508 100644 --- a/tests/main/httpWebSocket.test.ts +++ b/tests/main/httpWebSocket.test.ts @@ -124,7 +124,7 @@ describe('desktop WebSocket proxy', () => { await executeWebSocketProxyRequest( { ...request(`ws://127.0.0.1:${port}/socket`), - headers: [{ id: 'h1', name: '__proto__', value: 'kept', enabled: true }], + headers: [{ name: '__proto__', value: 'kept', enabled: true }], }, { allowPrivateHosts: true } );