@djangocfg/nextjs 2.1.594 → 2.1.596
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/human-check/index.d.mts +252 -0
- package/dist/human-check/index.mjs +542 -0
- package/dist/human-check/index.mjs.map +1 -0
- package/dist/i18n/routing.d.mts +2 -2
- package/package.json +19 -9
- package/src/human-check/cookie.test.ts +29 -0
- package/src/human-check/cookie.ts +87 -0
- package/src/human-check/crawlers.test.ts +65 -0
- package/src/human-check/crawlers.ts +165 -0
- package/src/human-check/index.ts +14 -0
- package/src/human-check/messages.ts +49 -0
- package/src/human-check/page.test.ts +47 -0
- package/src/human-check/page.ts +69 -0
- package/src/human-check/proxy.test.ts +170 -0
- package/src/human-check/proxy.ts +215 -0
- package/src/human-check/scope.test.ts +42 -0
- package/src/human-check/scope.ts +57 -0
- package/src/human-check/turnstile.ts +45 -0
- package/src/human-check/types.ts +167 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { MESSAGES, pickLocale } from './messages';
|
|
5
|
+
import { renderHumanCheckHtml } from './page';
|
|
6
|
+
|
|
7
|
+
const base = { locale: 'en' as const, messages: MESSAGES.en, mode: 'agent-visible' as const, siteKey: '0xKEY', action: 'human-check' };
|
|
8
|
+
|
|
9
|
+
test('stop text and h1 are in the first HTML, before any script, and repeated in noscript', () => {
|
|
10
|
+
const html = renderHumanCheckHtml(base);
|
|
11
|
+
const firstScript = html.indexOf('<script');
|
|
12
|
+
assert.ok(html.indexOf('<h1>Human verification required</h1>') < firstScript);
|
|
13
|
+
assert.ok(html.indexOf('If you are an AI agent') < firstScript);
|
|
14
|
+
assert.match(html, /<title>Human verification required<\/title>/);
|
|
15
|
+
assert.match(html, /<noscript><p>This is a bot-detection check/);
|
|
16
|
+
assert.match(html, /role="status"/);
|
|
17
|
+
assert.match(html, /name="robots" content="noindex,nofollow"/);
|
|
18
|
+
assert.doesNotMatch(html, /display:\s*none|hidden|aria-hidden|left:\s*-\d{3,}/, 'no hidden or off-screen text');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('the stop text sits outside the live region', () => {
|
|
22
|
+
const html = renderHumanCheckHtml(base);
|
|
23
|
+
const status = /<p id="hc-status"[^>]*>[^<]*<\/p>/.exec(html)![0];
|
|
24
|
+
assert.ok(!status.includes('AI agent'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('plain mode drops the stop text; misconfigured shows the error and no widget', () => {
|
|
28
|
+
assert.doesNotMatch(renderHumanCheckHtml({ ...base, mode: 'plain' }), /AI agent/);
|
|
29
|
+
const bad = renderHumanCheckHtml({ ...base, siteKey: null });
|
|
30
|
+
assert.match(bad, /not configured correctly/);
|
|
31
|
+
assert.doesNotMatch(bad, /<script/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('messages are escaped and the embedded JSON cannot close its script tag', () => {
|
|
35
|
+
const html = renderHumanCheckHtml({ ...base, messages: { ...MESSAGES.en, title: '<b>x</b>', failed: '</script><img>' } });
|
|
36
|
+
assert.match(html, /<title><b>x<\/b><\/title>/);
|
|
37
|
+
assert.doesNotMatch(html, /<\/script><img>/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('Russian copy and locale picking', () => {
|
|
41
|
+
const html = renderHumanCheckHtml({ ...base, locale: 'ru', messages: MESSAGES.ru });
|
|
42
|
+
assert.match(html, /<html lang="ru">/);
|
|
43
|
+
assert.match(html, /Требуется проверка, что вы человек/);
|
|
44
|
+
assert.equal(pickLocale('ru', 'en', 'en'), 'ru');
|
|
45
|
+
assert.equal(pickLocale(null, 'de,ru;q=0.8', 'en'), 'ru');
|
|
46
|
+
assert.equal(pickLocale('xx', null, 'en'), 'en');
|
|
47
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { HumanCheckLocale, HumanCheckMessages } from './types';
|
|
2
|
+
|
|
3
|
+
export interface PageOptions {
|
|
4
|
+
locale: HumanCheckLocale;
|
|
5
|
+
messages: HumanCheckMessages;
|
|
6
|
+
mode: 'agent-visible' | 'plain';
|
|
7
|
+
/** Null when the gate is misconfigured: the page then shows the error and no widget. */
|
|
8
|
+
siteKey: string | null;
|
|
9
|
+
action: string;
|
|
10
|
+
/** Minimum time the message stays on screen after success, so a late snapshot still reads it. */
|
|
11
|
+
dwellMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const esc = (s: string) =>
|
|
15
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
16
|
+
|
|
17
|
+
/** JSON safe inside a <script> element. */
|
|
18
|
+
const scriptJson = (v: unknown) =>
|
|
19
|
+
JSON.stringify(v)
|
|
20
|
+
.replace(/</g, '\\u003c')
|
|
21
|
+
.replace(new RegExp(String.fromCharCode(0x2028), 'g'), '\\u2028')
|
|
22
|
+
.replace(new RegExp(String.fromCharCode(0x2029), 'g'), '\\u2029');
|
|
23
|
+
|
|
24
|
+
const CLIENT = `(function(){
|
|
25
|
+
var d=JSON.parse(document.getElementById('hc-data').textContent),m=d.m,s=document.getElementById('hc-status'),t0=Date.now(),id=null;
|
|
26
|
+
function say(t){s.textContent=t}
|
|
27
|
+
function go(n){setTimeout(function(){location.replace(n)},Math.max(0,d.dwell-(Date.now()-t0)))}
|
|
28
|
+
function tok(t){say(m.verifying);
|
|
29
|
+
fetch(location.pathname+location.search,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({token:t})})
|
|
30
|
+
.then(function(r){return r.json()}).then(function(j){
|
|
31
|
+
if(j&&j.ok){say(m.done);go(j.next)}else{say(m.failed);if(window.turnstile&&id!==null)window.turnstile.reset(id)}})
|
|
32
|
+
.catch(function(){say(m.failed);if(window.turnstile&&id!==null)window.turnstile.reset(id)})}
|
|
33
|
+
function mount(){if(!window.turnstile)return false;
|
|
34
|
+
id=window.turnstile.render('#hc-widget',{sitekey:d.siteKey,action:d.action,appearance:'always',callback:tok,
|
|
35
|
+
'error-callback':function(){say(m.blocked)},'expired-callback':function(){if(id!==null)window.turnstile.reset(id)}});return true}
|
|
36
|
+
var tries=0,iv=setInterval(function(){if(mount()){clearInterval(iv)}else if(++tries>100){clearInterval(iv);say(m.blocked)}},100);
|
|
37
|
+
})();`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Server-rendered gate page. The stop text and the h1 are in the first HTML,
|
|
41
|
+
* before any script, and the same text is repeated in <noscript>. No hidden
|
|
42
|
+
* text, no timers that move the page before verification succeeded.
|
|
43
|
+
*/
|
|
44
|
+
export function renderHumanCheckHtml(o: PageOptions): string {
|
|
45
|
+
const m = o.messages;
|
|
46
|
+
const agent = o.mode === 'agent-visible';
|
|
47
|
+
const stop = agent ? `<p id="hc-stop">${esc(m.stop)}</p>` : '';
|
|
48
|
+
const widget = o.siteKey
|
|
49
|
+
? `<div id="hc-widget"></div><p id="hc-status" role="status" aria-live="polite">${esc(m.checking)}</p>`
|
|
50
|
+
: `<p id="hc-status" role="status">${esc(m.misconfigured)}</p>`;
|
|
51
|
+
const scripts = o.siteKey
|
|
52
|
+
? `<script id="hc-data" type="application/json">${scriptJson({ m: { verifying: m.verifying, done: m.done, failed: m.failed, blocked: m.blocked }, siteKey: o.siteKey, action: o.action, dwell: o.dwellMs ?? 3000 })}</script>` +
|
|
53
|
+
`<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>` +
|
|
54
|
+
`<script>${CLIENT}</script>`
|
|
55
|
+
: '';
|
|
56
|
+
return (
|
|
57
|
+
`<!doctype html><html lang="${o.locale}"><head><meta charset="utf-8">` +
|
|
58
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
59
|
+
`<meta name="robots" content="noindex,nofollow">` +
|
|
60
|
+
`<title>${esc(m.title)}</title>` +
|
|
61
|
+
`<style>:root{color-scheme:light dark}body{margin:0;font:16px/1.5 system-ui,sans-serif;display:grid;min-height:100vh;place-items:center}` +
|
|
62
|
+
`main{max-width:34rem;padding:1.5rem}h1{font-size:1.5rem;margin:0 0 1rem}p{margin:0 0 1rem}#hc-widget{min-height:65px;margin:1rem 0}</style>` +
|
|
63
|
+
`</head><body><main>` +
|
|
64
|
+
`<h1>${esc(m.title)}</h1>${stop}<p id="hc-human">${esc(m.human)}</p>` +
|
|
65
|
+
`${widget}` +
|
|
66
|
+
`<noscript>${agent ? `<p>${esc(m.stop)}</p>` : ''}<p>${esc(m.noscript)}</p></noscript>` +
|
|
67
|
+
`</main>${scripts}</body></html>`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import { NextRequest } from 'next/server';
|
|
5
|
+
|
|
6
|
+
import { signClearance } from './cookie';
|
|
7
|
+
import { createCrawlerMatcher } from './crawlers';
|
|
8
|
+
import { createHumanCheck, type HumanCheckDeps } from './proxy';
|
|
9
|
+
|
|
10
|
+
import type { HumanCheckConfig } from './types';
|
|
11
|
+
|
|
12
|
+
const ENV = { HUMAN_CHECK_SECRET: 'sek', NEXT_PUBLIC_TURNSTILE_SITE_KEY: '0xSITE', TURNSTILE_SECRET_KEY: 'tsecret' };
|
|
13
|
+
const BROWSER = 'Mozilla/5.0 (Macintosh) Chrome/130';
|
|
14
|
+
const jsonRes = (o: unknown) => ({ ok: true, status: 200, json: async () => o }) as Response;
|
|
15
|
+
const LIST = { prefixes: [{ ipv4Prefix: '66.249.64.0/27' }] };
|
|
16
|
+
const crawlers = () => createCrawlerMatcher({ sources: ['u'], fetch: (async () => jsonRes(LIST)) as typeof fetch });
|
|
17
|
+
|
|
18
|
+
function make(config: HumanCheckConfig = {}, deps: HumanCheckDeps = {}) {
|
|
19
|
+
return createHumanCheck({ enabled: true, ...config }, { isDev: false, env: ENV, crawlers: crawlers(), log: () => {}, ...deps });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function req(path: string, init: { ua?: string; ip?: string; method?: string; cookie?: string; host?: string; body?: unknown; origin?: string } = {}) {
|
|
23
|
+
const headers: Record<string, string> = { 'user-agent': init.ua ?? BROWSER };
|
|
24
|
+
if (init.ip) headers['x-forwarded-for'] = init.ip;
|
|
25
|
+
if (init.cookie) headers.cookie = init.cookie;
|
|
26
|
+
if (init.origin) headers.origin = init.origin;
|
|
27
|
+
if (init.body !== undefined) headers['content-type'] = 'application/json';
|
|
28
|
+
return new NextRequest(`https://${init.host ?? 'app.example.com'}${path}`, {
|
|
29
|
+
method: init.method ?? 'GET',
|
|
30
|
+
headers,
|
|
31
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const passes = (r: Response | undefined) => r === undefined || r.headers.get('x-middleware-next') === '1';
|
|
36
|
+
|
|
37
|
+
test('disabled by default: nothing happens, the gate route does not exist', async () => {
|
|
38
|
+
const hc = createHumanCheck({}, { isDev: false, env: ENV, log: () => {} });
|
|
39
|
+
assert.equal(await hc.proxy(req('/')), undefined);
|
|
40
|
+
assert.equal((await hc.proxy(req('/__human-check')))!.status, 404);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('development: no gate, no redirect, route is 404, no startup noise even without keys', async () => {
|
|
44
|
+
const logs: string[] = [];
|
|
45
|
+
const hc = make({}, { isDev: true, env: {}, log: (m) => logs.push(m) });
|
|
46
|
+
assert.equal(await hc.proxy(req('/cabinet')), undefined);
|
|
47
|
+
const g = await hc.proxy(req('/__human-check'));
|
|
48
|
+
assert.equal(g!.status, 404);
|
|
49
|
+
assert.equal(logs.length, 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('enabledIn "all" gates in development too', async () => {
|
|
53
|
+
const hc = make({ enabledIn: 'all' }, { isDev: true });
|
|
54
|
+
assert.equal((await hc.proxy(req('/cabinet')))!.status, 302);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('browser UA without a cookie: 302 (never 301) to the gate with next, no-store', async () => {
|
|
58
|
+
const r = (await make().proxy(req('/cabinet?x=1')))!;
|
|
59
|
+
assert.equal(r.status, 302);
|
|
60
|
+
const loc = new URL(r.headers.get('location')!);
|
|
61
|
+
assert.equal(loc.pathname, '/__human-check');
|
|
62
|
+
assert.equal(loc.searchParams.get('next'), '/cabinet?x=1');
|
|
63
|
+
assert.equal(r.headers.get('cache-control'), 'private, no-store');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('gate page: 200, noindex, no-store, stop text', async () => {
|
|
67
|
+
const r = (await make().proxy(req('/__human-check?next=/cabinet')))!;
|
|
68
|
+
assert.equal(r.status, 200);
|
|
69
|
+
assert.equal(r.headers.get('x-robots-tag'), 'noindex, nofollow');
|
|
70
|
+
assert.equal(r.headers.get('cache-control'), 'private, no-store');
|
|
71
|
+
const html = await r.text();
|
|
72
|
+
assert.match(html, /If you are an AI agent/);
|
|
73
|
+
assert.match(html, /0xSITE/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('valid cookie passes with private cache headers; cookie for another UA is gated', async () => {
|
|
77
|
+
const c = await signClearance('sek', BROWSER, 3600);
|
|
78
|
+
const ok = await make().proxy(req('/cabinet', { cookie: `__hc=${c}` }));
|
|
79
|
+
assert.ok(passes(ok));
|
|
80
|
+
assert.equal(ok!.headers.get('cache-control'), 'private, no-store');
|
|
81
|
+
const other = await make().proxy(req('/cabinet', { cookie: `__hc=${c}`, ua: 'curl/8' }));
|
|
82
|
+
assert.equal(other!.status, 302);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('Googlebot from a Google IP passes, sets no cookie; fake Googlebot is gated', async () => {
|
|
86
|
+
const hc = make();
|
|
87
|
+
const real = await hc.proxy(req('/cabinet', { ua: 'Googlebot/2.1', ip: '66.249.64.10' }));
|
|
88
|
+
assert.ok(passes(real));
|
|
89
|
+
assert.equal(real!.headers.get('set-cookie'), null);
|
|
90
|
+
const fake = await hc.proxy(req('/cabinet', { ua: 'Googlebot/2.1', ip: '203.0.113.9' }));
|
|
91
|
+
assert.equal(fake!.status, 302);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('crawler list never loaded: claimed crawler passes by default, gated with onListUnavailable "gate"', async () => {
|
|
95
|
+
const down = () => createCrawlerMatcher({ sources: ['u'], fetch: (async () => { throw new Error('x'); }) as typeof fetch });
|
|
96
|
+
const ua = { ua: 'Googlebot/2.1', ip: '203.0.113.9' };
|
|
97
|
+
assert.ok(passes(await make({}, { crawlers: down() }).proxy(req('/a', ua))));
|
|
98
|
+
assert.equal((await make({ bypass: { onListUnavailable: 'gate' } }, { crawlers: down() }).proxy(req('/a', ua)))!.status, 302);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('preview bots: GET passes, POST does not', async () => {
|
|
102
|
+
const hc = make();
|
|
103
|
+
assert.ok(passes(await hc.proxy(req('/film/x', { ua: 'TelegramBot (like TwitterBot)' }))));
|
|
104
|
+
assert.equal((await hc.proxy(req('/film/x', { ua: 'TelegramBot', method: 'POST', body: {} })))!.status, 302);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('always-open paths and the out-of-scope marketing host are untouched; cabinet host is gated', async () => {
|
|
108
|
+
const hc = make({ scope: { kind: 'hosts', hosts: { 'app.example.com': 'site' } } });
|
|
109
|
+
for (const p of ['/robots.txt', '/sitemap.xml', '/.well-known/x', '/_next/static/a.js', '/api/og/film', '/healthz']) {
|
|
110
|
+
assert.equal(await hc.proxy(req(p)), undefined, p);
|
|
111
|
+
}
|
|
112
|
+
assert.equal(await hc.proxy(req('/pricing', { host: 'www.example.com' })), undefined);
|
|
113
|
+
assert.equal((await hc.proxy(req('/pricing', { host: 'app.example.com' })))!.status, 302);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('missing keys in production: loud log once, gate on, nobody passes', async () => {
|
|
117
|
+
const logs: string[] = [];
|
|
118
|
+
const hc = make({}, { env: {}, log: (m) => logs.push(m) });
|
|
119
|
+
assert.equal(logs.length, 1);
|
|
120
|
+
assert.match(logs[0] ?? '', /misconfigured/);
|
|
121
|
+
assert.equal((await hc.proxy(req('/cabinet')))!.status, 302);
|
|
122
|
+
assert.match(await (await hc.proxy(req('/__human-check')))!.text(), /not configured correctly/);
|
|
123
|
+
const v = await hc.proxy(req('/__human-check', { method: 'POST', body: { token: 't' } }));
|
|
124
|
+
assert.equal(v!.status, 503);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function verifyDeps(response: unknown) {
|
|
128
|
+
return { fetch: (async () => jsonRes(response)) as typeof fetch };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test('verify: success sets a signed HttpOnly cookie and returns a safe next', async () => {
|
|
132
|
+
const hc = make({}, verifyDeps({ success: true, hostname: 'app.example.com', action: 'human-check' }));
|
|
133
|
+
const r = (await hc.proxy(req('/__human-check?next=/cabinet', { method: 'POST', body: { token: 't' } })))!;
|
|
134
|
+
assert.equal(r.status, 200);
|
|
135
|
+
assert.deepEqual(await r.json(), { ok: true, next: '/cabinet' });
|
|
136
|
+
const sc = r.headers.get('set-cookie')!;
|
|
137
|
+
assert.match(sc, /^__hc=v1\./);
|
|
138
|
+
assert.match(sc, /HttpOnly/i);
|
|
139
|
+
assert.match(sc, /SameSite=lax/i);
|
|
140
|
+
assert.match(sc, /Secure/i);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('verify: next=https://evil.example falls back to /', async () => {
|
|
144
|
+
const hc = make({}, verifyDeps({ success: true, hostname: 'app.example.com', action: 'human-check' }));
|
|
145
|
+
const r = (await hc.proxy(req(`/__human-check?next=${encodeURIComponent('https://evil.example')}`, { method: 'POST', body: { token: 't' } })))!;
|
|
146
|
+
assert.equal((await r.json()).next, '/');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('verify: wrong hostname or action is rejected without a cookie; reused token asks for a retry', async () => {
|
|
150
|
+
const host = await make({}, verifyDeps({ success: true, hostname: 'evil.example', action: 'human-check' })).proxy(req('/__human-check', { method: 'POST', body: { token: 't' } }));
|
|
151
|
+
assert.equal(host!.status, 403);
|
|
152
|
+
assert.equal(host!.headers.get('set-cookie'), null);
|
|
153
|
+
const act = await make({}, verifyDeps({ success: true, hostname: 'app.example.com', action: 'login' })).proxy(req('/__human-check', { method: 'POST', body: { token: 't' } }));
|
|
154
|
+
assert.equal(act!.status, 403);
|
|
155
|
+
const dup = (await make({}, verifyDeps({ success: false, 'error-codes': ['timeout-or-duplicate'] })).proxy(req('/__human-check', { method: 'POST', body: { token: 't' } })))!;
|
|
156
|
+
assert.deepEqual(await dup.json(), { ok: false, reason: 'timeout-or-duplicate', retry: true });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('verify: cross-origin POST and empty token are refused', async () => {
|
|
160
|
+
const hc = make({}, verifyDeps({ success: true, hostname: 'app.example.com', action: 'human-check' }));
|
|
161
|
+
assert.equal((await hc.proxy(req('/__human-check', { method: 'POST', body: { token: 't' }, origin: 'https://evil.example' })))!.status, 403);
|
|
162
|
+
assert.equal((await hc.proxy(req('/__human-check', { method: 'POST', body: {} })))!.status, 400);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('an already cleared visitor opening the gate is sent on to next', async () => {
|
|
166
|
+
const c = await signClearance('sek', BROWSER, 3600);
|
|
167
|
+
const r = (await make().proxy(req('/__human-check?next=/cabinet%3Fa%3D1', { cookie: `__hc=${c}` })))!;
|
|
168
|
+
assert.equal(r.status, 302);
|
|
169
|
+
assert.equal(new URL(r.headers.get('location')!).pathname + new URL(r.headers.get('location')!).search, '/cabinet?a=1');
|
|
170
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-check gate for Next.js (proxy / middleware).
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* // proxy.ts
|
|
6
|
+
* import { createHumanCheck, withHumanCheck } from '@djangocfg/nextjs/human-check';
|
|
7
|
+
*
|
|
8
|
+
* const humanCheck = createHumanCheck({ enabled: true, scope: { kind: 'hosts', hosts: { 'app.example.com': 'site' } } });
|
|
9
|
+
* export default withHumanCheck(humanCheck, intlProxy);
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* The proxy serves the gate route itself (GET page, POST verify), so no app
|
|
13
|
+
* route is needed (an `app/__human-check` folder would be a private folder
|
|
14
|
+
* and never routable). Order: disabled -> dev skip -> always-open paths ->
|
|
15
|
+
* gate route -> scope -> crawler/preview bypass -> valid cookie -> 302 to gate.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isDev as apiIsDev } from '@djangocfg/api';
|
|
19
|
+
import { NextResponse } from 'next/server';
|
|
20
|
+
|
|
21
|
+
import { parseSecrets, signClearance, verifyClearance } from './cookie';
|
|
22
|
+
import { createCrawlerMatcher, isPreviewBot, type CrawlerMatcher } from './crawlers';
|
|
23
|
+
import { MESSAGES, pickLocale } from './messages';
|
|
24
|
+
import { renderHumanCheckHtml } from './page';
|
|
25
|
+
import { ALWAYS_OPEN_PATHS, resolveConfig, type HumanCheckConfig, type ResolvedHumanCheckConfig } from './types';
|
|
26
|
+
import { inScope, matchAny, safeNext } from './scope';
|
|
27
|
+
import { verifyTurnstile } from './turnstile';
|
|
28
|
+
|
|
29
|
+
import type { NextRequest } from 'next/server';
|
|
30
|
+
|
|
31
|
+
export interface HumanCheckDeps {
|
|
32
|
+
/** Test seam. Default: `isDev` from `@djangocfg/api`. */
|
|
33
|
+
isDev?: boolean;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
env?: Record<string, string | undefined>;
|
|
37
|
+
crawlers?: CrawlerMatcher;
|
|
38
|
+
log?: (msg: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type HumanCheckDecision = 'disabled' | 'dev' | 'open' | 'out-of-scope' | 'crawler' | 'preview' | 'cleared' | 'gate';
|
|
42
|
+
|
|
43
|
+
const GATE_HEADERS = {
|
|
44
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
45
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
46
|
+
'Cache-Control': 'private, no-store',
|
|
47
|
+
'Referrer-Policy': 'no-referrer',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function clientIp(h: Headers, trustCloudflare: boolean): string | null {
|
|
51
|
+
if (trustCloudflare) {
|
|
52
|
+
const cf = h.get('cf-connecting-ip');
|
|
53
|
+
if (cf) return cf.trim();
|
|
54
|
+
}
|
|
55
|
+
return h.get('x-real-ip')?.trim() ?? h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createHumanCheck(userConfig: HumanCheckConfig = {}, deps: HumanCheckDeps = {}) {
|
|
59
|
+
const cfg: ResolvedHumanCheckConfig = resolveConfig(userConfig);
|
|
60
|
+
const env = deps.env ?? process.env;
|
|
61
|
+
const dev = () => deps.isDev ?? apiIsDev;
|
|
62
|
+
const now = deps.now ?? Date.now;
|
|
63
|
+
const log = deps.log ?? ((m: string) => console.error(m));
|
|
64
|
+
|
|
65
|
+
/** The dev check comes first, before scope, cookie and Turnstile. */
|
|
66
|
+
const active = () => cfg.enabled && !(dev() && cfg.enabledIn !== 'all');
|
|
67
|
+
|
|
68
|
+
const crawlers =
|
|
69
|
+
deps.crawlers ??
|
|
70
|
+
(cfg.bypass.crawlers
|
|
71
|
+
? createCrawlerMatcher({ sources: cfg.bypass.crawlerSources, fetch: deps.fetch, now: deps.now })
|
|
72
|
+
: null);
|
|
73
|
+
|
|
74
|
+
const secrets = () => parseSecrets(env[cfg.cookie.secretEnv]);
|
|
75
|
+
const siteKey = () => env[cfg.turnstile.siteKeyEnv] || null;
|
|
76
|
+
const turnstileSecret = () => env[cfg.turnstile.secretKeyEnv] || null;
|
|
77
|
+
const misconfigured = () => secrets().length === 0 || !siteKey() || !turnstileSecret();
|
|
78
|
+
|
|
79
|
+
// Fail closed and loud: gate on, never a silent pass.
|
|
80
|
+
if (active() && misconfigured()) {
|
|
81
|
+
log(
|
|
82
|
+
`[human-check] ENABLED but misconfigured: set ${cfg.cookie.secretEnv}, ${cfg.turnstile.siteKeyEnv} and ${cfg.turnstile.secretKeyEnv}. ` +
|
|
83
|
+
'Every in-scope request will be sent to the gate and no one can pass.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const page = (req: NextRequest, status = 200) => {
|
|
88
|
+
const locale = pickLocale(req.nextUrl.searchParams.get('lang'), req.headers.get('accept-language'), cfg.message.defaultLocale);
|
|
89
|
+
const messages = { ...MESSAGES[locale], ...(cfg.message.overrides[locale] ?? {}) };
|
|
90
|
+
const key = misconfigured() ? null : siteKey();
|
|
91
|
+
const html = renderHumanCheckHtml({ locale, messages, mode: cfg.message.mode, siteKey: key, action: cfg.turnstile.action });
|
|
92
|
+
return new NextResponse(req.method === 'HEAD' ? null : html, { status, headers: GATE_HEADERS });
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async function verifyPost(req: NextRequest): Promise<NextResponse> {
|
|
96
|
+
const json = (body: unknown, status: number) =>
|
|
97
|
+
NextResponse.json(body, { status, headers: { 'Cache-Control': 'private, no-store', 'X-Robots-Tag': 'noindex, nofollow' } });
|
|
98
|
+
const origin = req.headers.get('origin');
|
|
99
|
+
if (origin) {
|
|
100
|
+
let host = '';
|
|
101
|
+
try {
|
|
102
|
+
host = new URL(origin).host;
|
|
103
|
+
} catch {
|
|
104
|
+
/* invalid origin falls through to the mismatch below */
|
|
105
|
+
}
|
|
106
|
+
if (host !== req.nextUrl.host) return json({ ok: false, reason: 'bad-origin', retry: false }, 403);
|
|
107
|
+
}
|
|
108
|
+
if (misconfigured()) return json({ ok: false, reason: 'misconfigured', retry: false }, 503);
|
|
109
|
+
let token = '';
|
|
110
|
+
try {
|
|
111
|
+
const body = (await req.json()) as { token?: unknown };
|
|
112
|
+
if (typeof body.token === 'string') token = body.token;
|
|
113
|
+
} catch {
|
|
114
|
+
/* empty token is rejected below */
|
|
115
|
+
}
|
|
116
|
+
if (!token || token.length > 4096) return json({ ok: false, reason: 'no-token', retry: true }, 400);
|
|
117
|
+
const result = await verifyTurnstile({
|
|
118
|
+
secret: turnstileSecret()!,
|
|
119
|
+
token,
|
|
120
|
+
remoteIp: clientIp(req.headers, cfg.bypass.trustCloudflareIp),
|
|
121
|
+
expectedHostnames: cfg.turnstile.hostnames ?? [req.nextUrl.hostname],
|
|
122
|
+
expectedAction: cfg.turnstile.action,
|
|
123
|
+
fetch: deps.fetch,
|
|
124
|
+
});
|
|
125
|
+
if (!result.ok) return json({ ok: false, reason: result.reason, retry: result.retry }, 403);
|
|
126
|
+
const signer = secrets()[0];
|
|
127
|
+
if (!signer) return json({ ok: false, reason: 'misconfigured', retry: false }, 503);
|
|
128
|
+
const value = await signClearance(signer, req.headers.get('user-agent'), cfg.cookie.ttlSeconds, now());
|
|
129
|
+
const res = json({ ok: true, next: safeNext(req.nextUrl.searchParams.get('next'), cfg.gatePath) }, 200);
|
|
130
|
+
res.cookies.set(cfg.cookie.name, value, {
|
|
131
|
+
httpOnly: true,
|
|
132
|
+
secure: req.nextUrl.protocol === 'https:',
|
|
133
|
+
sameSite: 'lax',
|
|
134
|
+
path: '/',
|
|
135
|
+
maxAge: cfg.cookie.ttlSeconds,
|
|
136
|
+
});
|
|
137
|
+
return res;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function decide(req: NextRequest): Promise<HumanCheckDecision> {
|
|
141
|
+
if (!cfg.enabled) return 'disabled';
|
|
142
|
+
if (!active()) return 'dev';
|
|
143
|
+
const { pathname } = req.nextUrl;
|
|
144
|
+
if (matchAny(ALWAYS_OPEN_PATHS, pathname) || matchAny(cfg.bypass.paths, pathname)) return 'open';
|
|
145
|
+
if (!inScope(cfg.scope, req.nextUrl.hostname, pathname)) return 'out-of-scope';
|
|
146
|
+
const ua = req.headers.get('user-agent');
|
|
147
|
+
if (cfg.bypass.previewBots && (req.method === 'GET' || req.method === 'HEAD') && isPreviewBot(ua)) return 'preview';
|
|
148
|
+
if (crawlers) {
|
|
149
|
+
const verdict = await crawlers.check(clientIp(req.headers, cfg.bypass.trustCloudflareIp), ua);
|
|
150
|
+
if (verdict === 'verified' || (verdict === 'unknown' && cfg.bypass.onListUnavailable === 'allow-claimed')) return 'crawler';
|
|
151
|
+
}
|
|
152
|
+
if (await verifyClearance(secrets(), req.cookies.get(cfg.cookie.name)?.value, ua, now())) return 'cleared';
|
|
153
|
+
return 'gate';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Returns a response when the gate handles the request (gate page, verify, redirect, or a
|
|
158
|
+
* pass-through with cache headers) and `undefined` when the request is none of its business.
|
|
159
|
+
*/
|
|
160
|
+
async function proxy(req: NextRequest): Promise<NextResponse | undefined> {
|
|
161
|
+
const { pathname } = req.nextUrl;
|
|
162
|
+
if (pathname === cfg.gatePath) {
|
|
163
|
+
// Off and dev: the route does not exist.
|
|
164
|
+
if (!active()) return new NextResponse(null, { status: 404, headers: { 'Cache-Control': 'private, no-store' } });
|
|
165
|
+
if (req.method === 'POST') return verifyPost(req);
|
|
166
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return new NextResponse(null, { status: 405, headers: { Allow: 'GET, HEAD, POST' } });
|
|
167
|
+
if (await verifyClearance(secrets(), req.cookies.get(cfg.cookie.name)?.value, req.headers.get('user-agent'), now())) {
|
|
168
|
+
const to = req.nextUrl.clone();
|
|
169
|
+
const [p, q] = safeNext(req.nextUrl.searchParams.get('next'), cfg.gatePath).split('?');
|
|
170
|
+
to.pathname = p ?? '/';
|
|
171
|
+
to.search = q ? `?${q}` : '';
|
|
172
|
+
return NextResponse.redirect(to, 302);
|
|
173
|
+
}
|
|
174
|
+
return page(req);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const decision = await decide(req);
|
|
178
|
+
switch (decision) {
|
|
179
|
+
case 'gate': {
|
|
180
|
+
const to = req.nextUrl.clone();
|
|
181
|
+
const next = `${pathname}${req.nextUrl.search}`;
|
|
182
|
+
to.pathname = cfg.gatePath;
|
|
183
|
+
to.search = `?next=${encodeURIComponent(next)}`;
|
|
184
|
+
const res = NextResponse.redirect(to, 302);
|
|
185
|
+
res.headers.set('Cache-Control', 'private, no-store');
|
|
186
|
+
res.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
|
187
|
+
return res;
|
|
188
|
+
}
|
|
189
|
+
case 'crawler':
|
|
190
|
+
case 'preview':
|
|
191
|
+
case 'cleared': {
|
|
192
|
+
if (cfg.cache === 'leave') return undefined;
|
|
193
|
+
// A crawler response must not be cached and served to a human without a cookie.
|
|
194
|
+
const res = NextResponse.next();
|
|
195
|
+
res.headers.set('Cache-Control', 'private, no-store');
|
|
196
|
+
res.headers.append('Vary', 'Cookie, User-Agent');
|
|
197
|
+
return res;
|
|
198
|
+
}
|
|
199
|
+
default:
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { proxy, decide, config: cfg };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type HumanCheck = ReturnType<typeof createHumanCheck>;
|
|
208
|
+
|
|
209
|
+
/** Compose with another proxy (e.g. the i18n one): the gate runs first. */
|
|
210
|
+
export function withHumanCheck<A extends unknown[], R>(
|
|
211
|
+
humanCheck: HumanCheck,
|
|
212
|
+
next: (req: NextRequest, ...args: A) => R | Promise<R>,
|
|
213
|
+
) {
|
|
214
|
+
return async (req: NextRequest, ...args: A): Promise<NextResponse | R> => (await humanCheck.proxy(req)) ?? next(req, ...args);
|
|
215
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import type { HumanCheckScope } from './types';
|
|
5
|
+
import { inScope, matchHost, matchPath, safeNext } from './scope';
|
|
6
|
+
|
|
7
|
+
test('path patterns: exact, prefix, star suffix', () => {
|
|
8
|
+
assert.ok(matchPath('/app/*', '/app'));
|
|
9
|
+
assert.ok(matchPath('/app/*', '/app/x/y'));
|
|
10
|
+
assert.ok(!matchPath('/app/*', '/apple'));
|
|
11
|
+
assert.ok(matchPath('/sitemap*', '/sitemap-1.xml'));
|
|
12
|
+
assert.ok(matchPath('/pricing', '/pricing'));
|
|
13
|
+
assert.ok(!matchPath('/pricing', '/pricing/x'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('host patterns', () => {
|
|
17
|
+
assert.ok(matchHost('app.example.com', 'APP.example.com'));
|
|
18
|
+
assert.ok(matchHost('*.example.com', 'a.example.com'));
|
|
19
|
+
assert.ok(!matchHost('*.example.com', 'example.com'));
|
|
20
|
+
assert.ok(!matchHost('*.example.com', 'evilexample.com'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('site, routes and hosts scopes; marketing open, cabinet gated', () => {
|
|
24
|
+
assert.ok(inScope({ kind: 'site' }, 'x.com', '/anything'));
|
|
25
|
+
const routes: HumanCheckScope = { kind: 'routes', include: ['/cabinet/*'], exclude: ['/cabinet/public/*'] };
|
|
26
|
+
assert.ok(inScope(routes, 'x.com', '/cabinet/a'));
|
|
27
|
+
assert.ok(!inScope(routes, 'x.com', '/cabinet/public/a'));
|
|
28
|
+
assert.ok(!inScope(routes, 'x.com', '/pricing'));
|
|
29
|
+
const hosts: HumanCheckScope = { kind: 'hosts', hosts: { 'app.x.com': 'site', 'x.com': { include: ['/admin/*'] } } };
|
|
30
|
+
assert.ok(inScope(hosts, 'app.x.com', '/'));
|
|
31
|
+
assert.ok(inScope(hosts, 'x.com', '/admin/u'));
|
|
32
|
+
assert.ok(!inScope(hosts, 'x.com', '/'));
|
|
33
|
+
assert.ok(!inScope(hosts, 'other.com', '/'), 'unlisted host is not gated');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('next must be a same-origin relative path', () => {
|
|
37
|
+
const g = '/__human-check';
|
|
38
|
+
assert.equal(safeNext('/a?b=1', g), '/a?b=1');
|
|
39
|
+
for (const bad of ['https://evil.example', '//evil.example', '/\\evil.example', 'javascript:1', '/a\r\nb', '', null, undefined, '/__human-check?next=/']) {
|
|
40
|
+
assert.equal(safeNext(bad as string, g), '/', String(bad));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { HumanCheckScope, ScopeRule } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Path pattern: exact (`/pricing`), prefix (`/app/*` matches `/app` and everything under it)
|
|
5
|
+
* or bare-star suffix (`/sitemap*` matches `/sitemap.xml`, `/sitemap-1.xml`).
|
|
6
|
+
*/
|
|
7
|
+
export function matchPath(pattern: string, pathname: string): boolean {
|
|
8
|
+
if (pattern.endsWith('/*')) {
|
|
9
|
+
const base = pattern.slice(0, -2);
|
|
10
|
+
return pathname === base || pathname.startsWith(`${base}/`);
|
|
11
|
+
}
|
|
12
|
+
if (pattern.endsWith('*')) return pathname.startsWith(pattern.slice(0, -1));
|
|
13
|
+
return pathname === pattern;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const matchAny = (patterns: readonly string[], pathname: string) => patterns.some((p) => matchPath(p, pathname));
|
|
17
|
+
|
|
18
|
+
/** Host pattern: exact or `*.example.com`. Case-insensitive, port ignored. */
|
|
19
|
+
export function matchHost(pattern: string, hostname: string): boolean {
|
|
20
|
+
const h = hostname.toLowerCase();
|
|
21
|
+
const p = pattern.toLowerCase();
|
|
22
|
+
if (p.startsWith('*.')) return h.endsWith(p.slice(1)) && h.length > p.length - 1;
|
|
23
|
+
return h === p;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function applyRule(rule: ScopeRule, pathname: string): boolean {
|
|
27
|
+
if (rule === 'site') return true;
|
|
28
|
+
if (rule.exclude && matchAny(rule.exclude, pathname)) return false;
|
|
29
|
+
return matchAny(rule.include, pathname);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Is this host+path inside the gated scope? A host that is not listed in a `hosts` scope is not gated. */
|
|
33
|
+
export function inScope(scope: HumanCheckScope, hostname: string, pathname: string): boolean {
|
|
34
|
+
switch (scope.kind) {
|
|
35
|
+
case 'site':
|
|
36
|
+
return true;
|
|
37
|
+
case 'routes':
|
|
38
|
+
return applyRule({ include: scope.include, exclude: scope.exclude }, pathname);
|
|
39
|
+
case 'hosts': {
|
|
40
|
+
for (const [host, rule] of Object.entries(scope.hosts)) {
|
|
41
|
+
if (matchHost(host, hostname)) return applyRule(rule, pathname);
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `next` must be a same-origin relative path. Anything else becomes `/`. */
|
|
49
|
+
export function safeNext(raw: string | null | undefined, gatePath: string): string {
|
|
50
|
+
if (!raw || raw.length > 2048) return '/';
|
|
51
|
+
if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return '/';
|
|
52
|
+
// eslint-disable-next-line no-control-regex
|
|
53
|
+
if (/[\u0000-\u001f\u007f\\]/.test(raw)) return '/';
|
|
54
|
+
const path = raw.split(/[?#]/, 1)[0];
|
|
55
|
+
if (path === gatePath) return '/';
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
2
|
+
|
|
3
|
+
export interface SiteverifyResponse {
|
|
4
|
+
success: boolean;
|
|
5
|
+
hostname?: string;
|
|
6
|
+
action?: string;
|
|
7
|
+
'error-codes'?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type VerifyResult = { ok: true } | { ok: false; reason: string; retry: boolean };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Server-side Turnstile check. `success` alone is not enough: the token must
|
|
14
|
+
* have been issued for this host and this action, or a token minted on any
|
|
15
|
+
* other site that shares the key would clear the gate.
|
|
16
|
+
*/
|
|
17
|
+
export async function verifyTurnstile(args: {
|
|
18
|
+
secret: string;
|
|
19
|
+
token: string;
|
|
20
|
+
remoteIp?: string | null;
|
|
21
|
+
expectedHostnames: string[];
|
|
22
|
+
expectedAction: string;
|
|
23
|
+
fetch?: typeof fetch;
|
|
24
|
+
}): Promise<VerifyResult> {
|
|
25
|
+
const body = new URLSearchParams({ secret: args.secret, response: args.token });
|
|
26
|
+
if (args.remoteIp) body.set('remoteip', args.remoteIp);
|
|
27
|
+
let data: SiteverifyResponse;
|
|
28
|
+
try {
|
|
29
|
+
const res = await (args.fetch ?? fetch)(SITEVERIFY_URL, { method: 'POST', body, signal: AbortSignal.timeout(5000) });
|
|
30
|
+
data = (await res.json()) as SiteverifyResponse;
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false, reason: 'siteverify-unreachable', retry: true };
|
|
33
|
+
}
|
|
34
|
+
if (!data.success) {
|
|
35
|
+
const codes = data['error-codes'] ?? [];
|
|
36
|
+
// An expired or reused token is fixed by a new widget run.
|
|
37
|
+
const retry = codes.includes('timeout-or-duplicate') || codes.includes('invalid-input-response');
|
|
38
|
+
return { ok: false, reason: codes[0] ?? 'rejected', retry };
|
|
39
|
+
}
|
|
40
|
+
if (!data.hostname || !args.expectedHostnames.some((h) => h.toLowerCase() === data.hostname!.toLowerCase())) {
|
|
41
|
+
return { ok: false, reason: 'hostname-mismatch', retry: false };
|
|
42
|
+
}
|
|
43
|
+
if (data.action !== args.expectedAction) return { ok: false, reason: 'action-mismatch', retry: false };
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|