Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/fetch/classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ describe('fetch classification', () => {
it('recognizes explicit challenges but not bare forbidden responses', () => {
expect(isChallengeResponse(403, { server: 'cloudflare' }, 'Just a moment...')).toBe(true);
expect(isChallengeResponse(403, {}, 'forbidden')).toBe(false);
expect(isChallengeResponse(403, { server: 'cloudflare' }, 'forbidden')).toBe(true);
});
it('ignores third-party allow-lists in CSP and friends', () => {
const csp = "default-src 'self'; script-src https://www.google.com/recaptcha/ https://cdnjs.cloudflare.com/";
expect(isChallengeResponse(200, { 'content-security-policy': csp }, '<html>Hacker News</html>')).toBe(false);
expect(isChallengeResponse(403, { 'content-security-policy': csp }, 'forbidden')).toBe(false);
});
it('does not treat a served 200 as a challenge on headers alone', () => {
expect(isChallengeResponse(200, { server: 'cloudflare' }, '<html>real page</html>')).toBe(false);
expect(isChallengeResponse(200, { server: 'cloudflare' }, 'Just a moment...')).toBe(true);
});
it('recognizes script-heavy app shells', () => expect(isJavaScriptShell('<div id="root"></div><script src="/app.js"></script><script>boot()</script>')).toBe(true));
});
10 changes: 8 additions & 2 deletions src/fetch/classify.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
const challengeMarkers = /cloudflare|cf-chl|datadome|perimeterx|px-captcha|akamai|captcha|just a moment|verify you are human/i;
// Headers that say something about *this* response. CSP/report-to/link are allow-lists of third
// parties (cdnjs.cloudflare.com, google.com/recaptcha) and are evidence of nothing.
const signalHeaders = /^(?:server|cf-mitigated|cf-chl-[\w-]+|x-datadome[\w-]*|set-cookie)$/i;

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;
if (challengeMarkers.test(body.slice(0, 20_000))) return true;
// A 200 with a real body is a served page; headers alone (server: cloudflare) never prove otherwise.
if (status === 200) return false;
return Object.entries(headers).some(([key, value]) => signalHeaders.test(key) && challengeMarkers.test(value));
}

export function isJavaScriptShell(body: string): boolean {
Expand Down
Loading