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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/fetch/classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,13 @@ describe('fetch classification', () => {
expect(isChallengeResponse(403, {}, 'forbidden')).toBe(false);
});
it('recognizes script-heavy app shells', () => expect(isJavaScriptShell('<div id="root"></div><script src="/app.js"></script><script>boot()</script>')).toBe(true));
it('does not flag a Cloudflare-fronted 200 with a normal body', () => {
expect(isChallengeResponse(200, { server: 'cloudflare', 'cf-cache-status': 'HIT' }, '<html><body>Hello world</body></html>')).toBe(false);
});
it('flags a 200 whose body shows an actual challenge page', () => {
expect(isChallengeResponse(200, { server: 'cloudflare' }, 'Just a moment...')).toBe(true);
});
it('flags a challenge header on a non-200 status even without body evidence', () => {
expect(isChallengeResponse(403, { 'cf-mitigated': 'challenge' }, 'forbidden')).toBe(true);
});
});
15 changes: 12 additions & 3 deletions src/fetch/classify.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
const challengeMarkers = /cloudflare|cf-chl|datadome|perimeterx|px-captcha|akamai|captcha|just a moment|verify you are human/i;
// Matched only against the response body: CDN *names* (e.g. "server: cloudflare") are not
// evidence of a challenge, since most of the web sits behind one on perfectly good responses.
const bodyChallengeMarkers = /cf-chl|datadome|perimeterx|px-captcha|akamai.*captcha|captcha|just a moment|checking your browser|verify you are human|attention required/i;
// Header *names* that only ever appear when a challenge actually fired.
const challengeHeaderNames = ['cf-mitigated', 'x-datadome-captcha', 'x-px-block'];

export function isChallengeResponse(status: number, headers: Record<string, string>, body: string): boolean {
const evidence = `${Object.entries(headers).map(([key, value]) => `${key}:${value}`).join('\n')}\n${body.slice(0, 20_000)}`;
return challengeMarkers.test(evidence) && (status === 403 || status === 429 || status === 503 || status === 200);
if (status !== 403 && status !== 429 && status !== 503 && status !== 200) return false;
const bodyHit = bodyChallengeMarkers.test(body.slice(0, 20_000));
// A 200 is only a challenge if the body itself shows one; generic CDN headers on a normal
// 200 (the common case for any Cloudflare-fronted site) must not trip this.
if (status === 200) return bodyHit;
const headerHit = challengeHeaderNames.some(name => headers[name] !== undefined);
return bodyHit || headerHit;
}

export function isJavaScriptShell(body: string): boolean {
Expand Down
67 changes: 65 additions & 2 deletions src/fetch/safe-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,72 @@
import { describe, expect, it } from 'vitest';
import { isSafeAddress } from './safe-proxy.js';
import * as net from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import { createSafeProxy, isSafeAddress } from './safe-proxy.js';

describe('isSafeAddress', () => {
it.each(['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '169.254.169.254', '0.0.0.0', '::1', '::', 'fe80::1', '::ffff:127.0.0.1'])('rejects private address %s', address => {
expect(isSafeAddress(address)).toBe(false);
});
it('allows public IPv4 addresses', () => expect(isSafeAddress('93.184.216.34')).toBe(true));
});

describe('createSafeProxy CONNECT tunnel', () => {
const cleanup: Array<() => void | Promise<void>> = [];
afterEach(async () => { await Promise.all(cleanup.splice(0).map(fn => fn())); });

async function startOrigin(onSocket?: (socket: net.Socket) => void): Promise<{ port: number; server: net.Server }> {
const server = net.createServer(socket => onSocket?.(socket));
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
cleanup.push(() => new Promise<void>(resolve => server.close(() => resolve())));
return { port: (server.address() as net.AddressInfo).port, server };
}

async function openTunnel(proxyUrl: string, originPort: number): Promise<net.Socket> {
const url = new URL(proxyUrl);
const client = net.connect(Number(url.port), url.hostname);
cleanup.push(() => { client.destroy(); });
await new Promise<void>((resolve, reject) => {
client.once('connect', () => client.write(`CONNECT 127.0.0.1:${originPort} HTTP/1.1\r\nHost: 127.0.0.1:${originPort}\r\n\r\n`));
client.once('data', () => resolve());
client.once('error', reject);
});
return client;
}

it('does not crash the process when the client resets mid-tunnel', async () => {
const { port: originPort } = await startOrigin(socket => {
const interval = setInterval(() => { if (!socket.destroyed) socket.write('x'.repeat(4096)); }, 5);
socket.on('close', () => clearInterval(interval));
cleanup.push(() => clearInterval(interval));
});

const proxy = await createSafeProxy({ allowPrivate: true });
cleanup.push(() => proxy.close());
const client = await openTunnel(proxy.url, originPort);

const uncaught: unknown[] = [];
const onUncaughtException = (error: unknown) => uncaught.push(error);
process.on('uncaughtException', onUncaughtException);

try {
// Reset instead of a clean FIN so the proxy's next write to this socket fails
// (reproduces the EPIPE from #283: writing to an already half-closed peer).
client.resetAndDestroy ? client.resetAndDestroy() : client.destroy();
await new Promise(resolve => setTimeout(resolve, 150));
expect(uncaught).toEqual([]);
} finally {
process.removeListener('uncaughtException', onUncaughtException);
}
});

it('close() tears down in-flight tunnels instead of hanging', async () => {
const { port: originPort } = await startOrigin(socket => socket.on('data', () => {}));
const proxy = await createSafeProxy({ allowPrivate: true });
await openTunnel(proxy.url, originPort);

const closed = await Promise.race([
proxy.close().then(() => true),
new Promise<boolean>(resolve => setTimeout(() => resolve(false), 1000)),
]);
expect(closed).toBe(true);
});
});
26 changes: 21 additions & 5 deletions src/fetch/safe-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { lookup as dnsLookup } from 'node:dns';
import * as http from 'node:http';
import * as net from 'node:net';
import type { Duplex } from 'node:stream';

export interface SafeProxy { url: string; close(): Promise<void>; }
export interface SafeProxyOptions { allowPrivate?: boolean; lookup?: typeof dnsLookup; }
Expand Down Expand Up @@ -38,6 +39,8 @@ async function resolve(host: string, lookup: typeof dnsLookup, allowPrivate: boo
export async function createSafeProxy(options: SafeProxyOptions = {}): Promise<SafeProxy> {
const lookup = options.lookup ?? dnsLookup;
const allowPrivate = options.allowPrivate === true;
const sockets = new Set<net.Socket | Duplex>();
const trackSocket = (socket: net.Socket | Duplex) => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); };
const server = http.createServer(async (request, response) => {
try {
const target = new URL(request.url ?? '');
Expand All @@ -46,22 +49,35 @@ export async function createSafeProxy(options: SafeProxyOptions = {}): Promise<S
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
});
upstream.on('error', error => response.destroy(error));
upstream.on('error', () => { upstream.destroy(); response.destroy(); });
request.on('error', () => upstream.destroy());
request.pipe(upstream);
} catch (error) { response.writeHead(403).end(error instanceof Error ? error.message : 'Unsafe fetch destination'); }
});
server.on('connect', async (request, client, head) => {
trackSocket(client);
let upstream: net.Socket | undefined;
// Persistent (not `once`) handlers: writes to an already half-closed peer keep emitting
// 'error', and with no listener at all Node throws and kills the process (EPIPE crash).
client.on('error', () => { client.destroy(); upstream?.destroy(); });
try {
const [host, portText] = (request.url ?? '').replace(/^\[/, '').replace(']', '').split(':');
if (!host) throw new Error('Invalid CONNECT target');
const address = await resolve(host, lookup, allowPrivate);
const upstream = net.connect({ host: address, port: Number(portText) || 443 });
upstream.once('connect', () => { client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); if (head.length) upstream.write(head); upstream.pipe(client); client.pipe(upstream); });
upstream.once('error', error => client.destroy(error));
upstream = net.connect({ host: address, port: Number(portText) || 443 });
trackSocket(upstream);
upstream.on('error', () => { upstream?.destroy(); client.destroy(); });
upstream.once('connect', () => { client.write('HTTP/1.1 200 Connection Established\r\n\r\n'); if (head.length) upstream!.write(head); upstream!.pipe(client); client.pipe(upstream!); });
} catch (error) { client.end(`HTTP/1.1 403 Forbidden\r\n\r\n${error instanceof Error ? error.message : ''}`); }
});
await new Promise<void>((resolveListen, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolveListen()); });
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Safe proxy did not bind');
return { url: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolveClose, reject) => server.close(error => error ? reject(error) : resolveClose())) };
return {
url: `http://127.0.0.1:${address.port}`,
close: () => new Promise((resolveClose, reject) => {
for (const socket of sockets) socket.destroy();
server.close(error => error ? reject(error) : resolveClose());
}),
};
}