diff --git a/CHANGELOG.md b/CHANGELOG.md index 4543f582..aedc8612 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 60a6b53d..9cf0597d 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 d5861bb3..e968c7a1 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 8caa6d27..8eabaecf 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 bab6156b..8a91314f 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 445ee1aa..50760d6e 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 08b0fa2e..f7f7cd50 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: [{ 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' });