Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/main/httpProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,8 @@ export async function executeHttpProxyRequest(
currentUrl,
HTTP_PROTOCOLS,
allowPrivateHosts,
lookupImpl
lookupImpl,
controller.signal
);
let hopDispatcher: Agent | null = null;
if (fetchImpl) {
Expand Down
21 changes: 19 additions & 2 deletions src/main/httpWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ function failure(
}

function toHeaderRecord(request: HttpRequestV1): Record<string, string> {
const headers: Record<string, string> = {};
// No prototype: a header named `__proto__` must stay a header.
const headers: Record<string, string> = 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;
Expand Down Expand Up @@ -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 ?? '';
Expand Down
29 changes: 25 additions & 4 deletions src/main/networkTargetPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ export function isPrivateAddress(ip: string): boolean {
async function assertHostAllowed(
rawHostname: string,
allowPrivateHosts: boolean,
lookupImpl: LookupImpl
lookupImpl: LookupImpl,
signal?: AbortSignal
): Promise<LookupAddress[]> {

// WHATWG `URL` keeps the square brackets on an IPv6 host (`[::1]`), and
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(abortError(signal));
return new Promise<T>((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<LookupAddress>;
Expand All @@ -260,7 +279,8 @@ export async function resolveGuardedNetworkTarget(
rawUrl: string,
allowedProtocols: ReadonlySet<string>,
allowPrivateHosts: boolean,
lookupImpl: LookupImpl
lookupImpl: LookupImpl,
signal?: AbortSignal
): Promise<GuardedNetworkTarget> {
let url: URL;
try {
Expand All @@ -274,7 +294,8 @@ export async function resolveGuardedNetworkTarget(
const addresses = await assertHostAllowed(
url.hostname,
allowPrivateHosts,
lookupImpl
lookupImpl,
signal
);
return { url, addresses };
}
11 changes: 11 additions & 0 deletions tests/main/httpProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
41 changes: 41 additions & 0 deletions tests/main/httpWebSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>(() => {});
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' });
Expand Down
Loading