ident
All repositories: gitoria
16.3 KB
// tests/iplimit.mjs — THE PER-IP LIMIT GATE (mission 010). Starts its OWN servers (never// the dev server on :8351, mail only into sink files):// ident :8400 HL_HOST=127.0.0.1 (loopback only, as on Byrodin), IP limit 3 / 10 min,// day limit 1000; storage .scratch/iplimit-gate/main// ident :8401 short clocks: IP window 2 s, IP limit 3, day limit 5 (the long window)// and TWO real headless Chromes (debug ports 8720-8729 / 8730-8739, --disable-gpu, killed// by PID) that carry an X-Client-IP header each (CDP Network.setExtraHTTPHeaders) — what// nginx adds on Byrodin.//// node tests/iplimit.mjs//// Covers: POST /api/code per IP (two IPs via the header: the limit hits one, not the// other), X-Forwarded-For / CF-Connecting-IP / X-Real-IP never change the bucket, no// header = ONE shared bucket, IPv6 by /64, IPv4-mapped IPv6 = IPv4, the per-address limit// still applies across IPs, refused codes are not mailed, the old face `requestCode` is// gone, strict body / 405, the long (day) window and the short window's expiry, the// loopback bind (not reachable on the LAN address), and in real browsers: the refusal is// visible on the page, the other browser (other IP) still signs in.// Screenshots: .scratch/iplimit-*.png — look. Server logs: .scratch/iplimit-gate/*.logimport { spawn } from 'node:child_process';import { rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';import { dirname, join, resolve } from 'node:path';import { fileURLToPath } from 'node:url';import { networkInterfaces } from 'node:os';import { launchBrowser } from './cdp.mjs';if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';const HERE = dirname(fileURLToPath(import.meta.url));const APP = resolve(HERE, '..');const BIN = join(APP, 'bin/hybriel');const SCRATCH = join(APP, '.scratch');const G = join(SCRATCH, 'iplimit-gate');rmSync(G, { recursive: true, force: true });mkdirSync(G, { recursive: true });const P_MAIN = 8400, P_SHORT = 8401;const ID = `http://127.0.0.1:${P_MAIN}`;const IDS = `http://127.0.0.1:${P_SHORT}`;const SINK = join(G, 'main-mail.txt'), SINK_S = join(G, 'short-mail.txt');let failures = 0, passes = 0;function check(label, ok, detail = '') {console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);if (ok) passes++; else failures++;}const sleep = (ms) => new Promise(r => setTimeout(r, ms));const J = JSON.stringify;const procs = [];function start(name, env) {let log = '';const p = spawn(BIN, ['project.hl'], { cwd: APP, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });p.stdout.on('data', d => log += d); p.stderr.on('data', d => log += d);p.on('exit', () => writeFileSync(join(G, `${name}.log`), log));procs.push({ name, p, log: () => log });return p;}start('main', { IDENT_PORT: String(P_MAIN), HL_HOST: '127.0.0.1', IDENT_STORAGE: join(G, 'main'), IDENT_SESSIONS: join(G, 'main-sess') + '/', IDENT_MAIL_SINK: SINK, IDENT_IP_LIMIT: '3', IDENT_IP_DAY_LIMIT: '1000' });start('short', { IDENT_PORT: String(P_SHORT), IDENT_STORAGE: join(G, 'short'), IDENT_SESSIONS: join(G, 'short-sess') + '/', IDENT_MAIL_SINK: SINK_S, IDENT_IP_LIMIT: '3', IDENT_IP_WINDOW_MS: '2000', IDENT_IP_DAY_LIMIT: '5' });function mails(sink, email) {if (!existsSync(sink)) return [];return readFileSync(sink, 'utf8').split('\n').filter(l => l.startsWith(email + ' ')).map(l => l.split(' ')[1]);}const mailCount = (sink) => existsSync(sink) ? readFileSync(sink, 'utf8').split('\n').filter(Boolean).length : 0;// POST /api/code as a client behind nginx would arrive: `headers` are the extra onesasync function code(base, email, headers = {}, raw = null) {const r = await fetch(base + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: raw !== null ? raw : J({ email }) });const t = await r.text();let j = null; try { j = JSON.parse(t); } catch {}return { status: r.status, j, t };}const IPMSG = 'too many codes were requested from your network — wait a while and try again';const limited = (r) => r.status === 429 && r.j && r.j.error === IPMSG;const connected = (page, label) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });async function viewport(page, width, height) {await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });await sleep(250);}async function shot(page, name) {const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });writeFileSync(join(SCRATCH, `iplimit-${name}.png`), Buffer.from(data, 'base64'));}const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');async function askCode(page, email) {await page.waitForSelector('#email');await connected(page, 'socket before asking');await page.type('#email', email, { clear: true });await page.evaluate('document.querySelector("#message").textContent = ""');await page.click('#sendcode');// ident#20: a sent code navigates to the code page (/code) — wait for it to hydrateawait page.waitFor('(/\\/code$/.test(location.pathname) && !!document.querySelector("#code")) || /\\S/.test(document.querySelector("#message").textContent)', { label: 'code page or message after ' + email });if (await page.evaluate('/\\/code$/.test(location.pathname)')) await connected(page, 'code page hydrated');}let A, B, a, b;try {for (const [name, url] of [['main', ID + '/'], ['short', IDS + '/']]) {let up = false;for (let i = 0; i < 80; i++) { try { const r = await fetch(url); if (r.ok) { up = true; break; } } catch {} await sleep(250); }if (!up) throw new Error(`${name} server did not come up\n` + procs.find(p => p.name === name).log());}// ---- the loopback bind (HL_HOST=127.0.0.1, as on Byrodin) ------------------------------const lan = Object.values(networkInterfaces()).flat().find(i => i && i.family === 'IPv4' && !i.internal);let lanReach = 'no LAN address';if (lan) { try { const r = await fetch(`http://${lan.address}:${P_MAIN}/`, { signal: AbortSignal.timeout(2000) }); lanReach = 'answered ' + r.status; } catch (e) { lanReach = 'refused'; } }check(`bind: HL_HOST=127.0.0.1 → not reachable on the LAN address (${lan && lan.address})`, lanReach === 'refused', lanReach);let lanShort = 'no LAN address';if (lan) { try { const r = await fetch(`http://${lan.address}:${P_SHORT}/`, { signal: AbortSignal.timeout(2000) }); lanShort = 'answered ' + r.status; } catch (e) { lanShort = 'refused'; } }check('bind: without HL_HOST → 0.0.0.0 as before (the LAN address answers 200)', lanShort === 'answered 200', lanShort);// ---- two IPs via the header: the limit hits one, not the other --------------------------let r;for (const n of [1, 2, 3]) {r = await code(ID, `x${n}@example.org`, { 'X-Client-IP': '203.0.113.7' });check(`ip 203.0.113.7: code ${n} of 3 → 200, mailed`, r.status === 200 && r.j.email === `x${n}@example.org` && mails(SINK, `x${n}@example.org`).length === 1, r.t);}r = await code(ID, '[email protected]', { 'X-Client-IP': '203.0.113.7' });check('ip 203.0.113.7: the 4th code (another address) → 429 with the reason, NOT mailed', limited(r) && mails(SINK, '[email protected]').length === 0, r.t);r = await code(ID, '[email protected]', { 'X-Client-IP': '198.51.100.9' });check('ip 198.51.100.9: the same address from another IP → 200, mailed', r.status === 200 && mails(SINK, '[email protected]').length === 1, r.t);// ---- the header cannot be bypassed with another IP header -------------------------------const before = mailCount(SINK);r = await code(ID, '[email protected]', { 'X-Client-IP': '203.0.113.7', 'X-Forwarded-For': '192.0.2.1', 'CF-Connecting-IP': '192.0.2.2', 'X-Real-IP': '192.0.2.3', 'Forwarded': 'for=192.0.2.4' });check('bypass: X-Forwarded-For / CF-Connecting-IP / X-Real-IP / Forwarded beside X-Client-IP → still 429', limited(r), r.t);r = await code(ID, '[email protected]', { 'x-client-ip': '203.0.113.7' });check('bypass: the header in lower case is the same header → 429', limited(r), r.t);r = await code(ID, '[email protected]', { 'X-Client-IP': ' 203.0.113.7 ' });check('bypass: surrounding blanks are trimmed → 429', limited(r), r.t);check('bypass: nothing was mailed by the refused requests', mailCount(SINK) === before, String(mailCount(SINK) - before));// ---- no X-Client-IP (dev, no nginx): ONE shared bucket, other headers ignored ----------const direct = [];for (const [n, fwd] of [[1, '192.0.2.11'], [2, '192.0.2.12'], [3, '192.0.2.13'], [4, '192.0.2.14']]) {direct.push(await code(ID, `d${n}@example.org`, { 'X-Forwarded-For': fwd, 'CF-Connecting-IP': fwd }));}check('no header: 3 codes pass, each with a different X-Forwarded-For / CF-Connecting-IP', direct.slice(0, 3).every(x => x.status === 200), J(direct.slice(0, 3).map(x => x.status)));check('no header: the 4th → 429 (one shared bucket; the forwarded headers do not split it)', limited(direct[3]) && mails(SINK, '[email protected]').length === 0, direct[3].t);r = await code(ID, '[email protected]', { 'X-Client-IP': '' });check('an EMPTY X-Client-IP counts as no header (shared bucket) → 429', limited(r), r.t);// ---- IPv6: one bucket per /64; IPv4-mapped = the IPv4 address ------------------------const v6 = [];for (const [n, ip] of [[1, '2001:db8:aa:1::1'], [2, '2001:0DB8:00AA:0001:ffff::2'], [3, '2001:db8:aa:1:1:2:3:4'], [4, '2001:db8:aa:1::99']]) {v6.push(await code(ID, `v${n}@example.org`, { 'X-Client-IP': ip }));}check('ipv6: 3 addresses of one /64 (written differently) pass', v6.slice(0, 3).every(x => x.status === 200), J(v6.map(x => x.status)));check('ipv6: a 4th address of the same /64 → 429', limited(v6[3]), v6[3].t);r = await code(ID, '[email protected]', { 'X-Client-IP': '2001:db8:aa:2::1' });check('ipv6: the next /64 is another bucket → 200', r.status === 200, r.t);r = await code(ID, '[email protected]', { 'X-Client-IP': '::ffff:203.0.113.7' });check('ipv4-mapped ipv6 ::ffff:203.0.113.7 = 203.0.113.7 → 429', limited(r), r.t);// ---- the per-address limit still applies (3 per address, across IPs) --------------------const per = [];for (const n of [1, 2, 3, 4]) per.push(await code(ID, '[email protected]', { 'X-Client-IP': `198.18.0.${n}` }));check('per address: 3 codes to one address from 3 IPs pass', per.slice(0, 3).every(x => x.status === 200), J(per.map(x => x.status)));check('per address: the 4th from a 4th IP → 429 "sent to this address", mailed 3 times', per[3].status === 429 && /too many codes were sent to this address/.test(per[3].j.error) && mails(SINK, '[email protected]').length === 3, per[3].t);// ---- the route itself ---------------------------------------------------------------r = await fetch(ID + '/api/code');check('GET /api/code → 405', r.status === 405, String(r.status));r = await code(ID, null, { 'X-Client-IP': '198.19.0.1' }, '{"email":"[email protected]","ip":"1.2.3.4"}');check('strict body: an unknown field (ip) → 400 naming it', r.status === 400 && r.j.error === 'unknown field: ip', r.t);r = await code(ID, null, { 'X-Client-IP': '198.19.0.1' }, '{bad');check('invalid JSON → 400, no source path', r.status === 400 && /not valid JSON/.test(r.j.error) && !/\.hl/.test(r.t), r.t);r = await code(ID, 'not-an-address', { 'X-Client-IP': '198.19.0.1' });check('not an address → 400', r.status === 400 && r.j.error === 'that is not an email address', r.t);const fr = await fetch(ID + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: J({ t: 'emit', i: 1, event: 'requestCode', payload: ['[email protected]'] }) });const fj = await fr.json();check('the old face requestCode is gone (no handler), nothing mailed', fj.ok === false && /no class/.test(fj.error || '') && mails(SINK, '[email protected]').length === 0, J(fj));// ---- the long window (day limit 5) and the short window's expiry (2 s) ------------------const s = [];for (const n of [1, 2, 3, 4]) s.push(await code(IDS, `s${n}@example.org`, { 'X-Client-IP': '192.0.2.50' }));check('short server: 3 pass, the 4th in the window → 429', s.slice(0, 3).every(x => x.status === 200) && limited(s[3]), J(s.map(x => x.status)));await sleep(2200);const s2 = [await code(IDS, '[email protected]', { 'X-Client-IP': '192.0.2.50' }), await code(IDS, '[email protected]', { 'X-Client-IP': '192.0.2.50' })];check('short server: after the 2 s window 2 more pass (5 in the long window)', s2.every(x => x.status === 200), J(s2.map(x => x.status)));await sleep(2200);r = await code(IDS, '[email protected]', { 'X-Client-IP': '192.0.2.50' });check('short server: the short window is free again, but the day limit (5) → 429, not mailed', limited(r) && mails(SINK_S, '[email protected]').length === 0, r.t);r = await code(IDS, '[email protected]', { 'X-Client-IP': '192.0.2.51' });check('short server: another IP is unaffected → 200', r.status === 200, r.t);// ---- real browsers: one IP each (nginx's header, set per browser) ----------------------A = await launchBrowser({ debugPortRange: [8720, 8729] });B = await launchBrowser({ debugPortRange: [8730, 8739] });a = await A.newPage();b = await B.newPage();for (const [pg, ip] of [[a, '100.64.0.1'], [b, '100.64.0.2']]) {await pg.send('Network.enable');await pg.send('Network.setExtraHTTPHeaders', { headers: { 'X-Client-IP': ip } });}await a.goto(ID + '/');for (const n of [1, 2, 3]) {await askCode(a, `ba${n}@example.org`);const ok = await a.evaluate('!!document.querySelector("#code")');check(`browser A (100.64.0.1): code ${n} → the code form, mailed`, ok && mails(SINK, `ba${n}@example.org`).length === 1, await a.text('#message'));await a.click('#back');await a.waitForSelector('#email');}await askCode(a, '[email protected]');const shown = await a.text('#message');check('browser A: the 4th → the refusal is VISIBLE on the page, still on the email form, not mailed', shown === IPMSG && await a.evaluate('!!document.querySelector("#emailform") && !document.querySelector("#code")') && mails(SINK, '[email protected]').length === 0, shown);await viewport(a, 390, 844); await shot(a, 'refused-390');check('layout: 390px refusal has no horizontal overflow', await noOverflow(a));await viewport(a, 1280, 900); await shot(a, 'refused-1280');check('layout: 1280px refusal has no horizontal overflow', await noOverflow(a));await b.goto(ID + '/');await askCode(b, '[email protected]');check('browser B (100.64.0.2): the same moment, another IP → the code form', await b.evaluate('!!document.querySelector("#code")'), await b.text('#message'));const bc = mails(SINK, '[email protected]').at(-1);await b.type('#code', bc, { clear: true });await b.click('#verify');await b.waitFor('!!document.querySelector("#identityform") || !!document.querySelector("#identities")', { label: 'B signed in' });check('browser B: the code signs in (first login: the welcome form)', await b.evaluate('!!document.querySelector("#welcome")') && (await b.text('#meemail')) === '[email protected]', await b.text('#message'));await b.goto(ID + '/');await b.waitForSelector('#identities');check('browser B: reload keeps the session (identsid cookie over the pinned listener)', (await b.text('#meemail')) === '[email protected]');// the refused fetch is a 429: Chrome logs "Failed to load resource … 429" — expectedconst expected = /favicon|status of 429 .*\/api\/code$/;const problems = [...a.problems(), ...b.problems()].filter(m => !expected.test(m.text));check('browsers: no console errors or warnings (besides the expected 429 line)', problems.length === 0, J(problems.slice(0, 5)));} catch (e) {failures++;console.log('FAIL (aborted) ' + (e && e.stack || e));for (const [n, pg] of [['A', a], ['B', b]]) if (pg) console.log(`console ${n}: ` + J(pg.messages.slice(-8)));} finally {for (const br of [A, B]) { if (br) { try { await br.close(); } catch {} } }for (const { p } of procs) { try { p.kill('SIGTERM'); } catch {} }await sleep(500);for (const { p } of procs) { if (p.exitCode === null && p.signalCode === null) { try { p.kill('SIGKILL'); } catch {} } }await sleep(200);console.log(`\n${passes} passed, ${failures} failed`);process.exit(failures ? 1 : 0);}
Branches
- mainmain branch
Latest commits
- 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
- 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
- 81b15b7bState of 2026-09-27, before the move to gitoriamre