diff --git a/src/fetch/classify.test.ts b/src/fetch/classify.test.ts
index 7d4f42f7..bb0682df 100644
--- a/src/fetch/classify.test.ts
+++ b/src/fetch/classify.test.ts
@@ -7,4 +7,13 @@ describe('fetch classification', () => {
expect(isChallengeResponse(403, {}, 'forbidden')).toBe(false);
});
it('recognizes script-heavy app shells', () => expect(isJavaScriptShell('
')).toBe(true));
+ it('does not flag a Cloudflare-fronted 200 with a normal body', () => {
+ expect(isChallengeResponse(200, { server: 'cloudflare', 'cf-cache-status': 'HIT' }, 'Hello world')).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);
+ });
});
diff --git a/src/fetch/classify.ts b/src/fetch/classify.ts
index 4051c13c..06bc726b 100644
--- a/src/fetch/classify.ts
+++ b/src/fetch/classify.ts
@@ -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, 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 {
diff --git a/src/fetch/safe-proxy.test.ts b/src/fetch/safe-proxy.test.ts
index 3d58353e..b477bf5e 100644
--- a/src/fetch/safe-proxy.test.ts
+++ b/src/fetch/safe-proxy.test.ts
@@ -1,5 +1,6 @@
-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 => {
@@ -7,3 +8,65 @@ describe('isSafeAddress', () => {
});
it('allows public IPv4 addresses', () => expect(isSafeAddress('93.184.216.34')).toBe(true));
});
+
+describe('createSafeProxy CONNECT tunnel', () => {
+ const cleanup: Array<() => void | Promise> = [];
+ 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(resolve => server.listen(0, '127.0.0.1', resolve));
+ cleanup.push(() => new Promise(resolve => server.close(() => resolve())));
+ return { port: (server.address() as net.AddressInfo).port, server };
+ }
+
+ async function openTunnel(proxyUrl: string, originPort: number): Promise {
+ const url = new URL(proxyUrl);
+ const client = net.connect(Number(url.port), url.hostname);
+ cleanup.push(() => { client.destroy(); });
+ await new Promise((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(resolve => setTimeout(() => resolve(false), 1000)),
+ ]);
+ expect(closed).toBe(true);
+ });
+});
diff --git a/src/fetch/safe-proxy.ts b/src/fetch/safe-proxy.ts
index 302f4710..ea277121 100644
--- a/src/fetch/safe-proxy.ts
+++ b/src/fetch/safe-proxy.ts
@@ -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; }
export interface SafeProxyOptions { allowPrivate?: boolean; lookup?: typeof dnsLookup; }
@@ -38,6 +39,8 @@ async function resolve(host: string, lookup: typeof dnsLookup, allowPrivate: boo
export async function createSafeProxy(options: SafeProxyOptions = {}): Promise {
const lookup = options.lookup ?? dnsLookup;
const allowPrivate = options.allowPrivate === true;
+ const sockets = new Set();
+ 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 ?? '');
@@ -46,22 +49,35 @@ export async function createSafeProxy(options: SafeProxyOptions = {}): Promise 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((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());
+ }),
+ };
}