gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit5fdbb6b25fdbb6b2ident mission 009: report — scratch folder notemre5fdbb6b2/tests/iplimit.mjs

16.3 KB

  1. // tests/iplimit.mjs — THE PER-IP LIMIT GATE (mission 010). Starts its OWN servers (never
  2. // the dev server on :8351, mail only into sink files):
  3. // ident :8400 HL_HOST=127.0.0.1 (loopback only, as on Byrodin), IP limit 3 / 10 min,
  4. // day limit 1000; storage .scratch/iplimit-gate/main
  5. // ident :8401 short clocks: IP window 2 s, IP limit 3, day limit 5 (the long window)
  6. // and TWO real headless Chromes (debug ports 8720-8729 / 8730-8739, --disable-gpu, killed
  7. // by PID) that carry an X-Client-IP header each (CDP Network.setExtraHTTPHeaders) — what
  8. // nginx adds on Byrodin.
  9. //
  10. // node tests/iplimit.mjs
  11. //
  12. // Covers: POST /api/code per IP (two IPs via the header: the limit hits one, not the
  13. // other), X-Forwarded-For / CF-Connecting-IP / X-Real-IP never change the bucket, no
  14. // header = ONE shared bucket, IPv6 by /64, IPv4-mapped IPv6 = IPv4, the per-address limit
  15. // still applies across IPs, refused codes are not mailed, the old face `requestCode` is
  16. // gone, strict body / 405, the long (day) window and the short window's expiry, the
  17. // loopback bind (not reachable on the LAN address), and in real browsers: the refusal is
  18. // visible on the page, the other browser (other IP) still signs in.
  19. // Screenshots: .scratch/iplimit-*.png — look. Server logs: .scratch/iplimit-gate/*.log
  20. import { spawn } from 'node:child_process';
  21. import { rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
  22. import { dirname, join, resolve } from 'node:path';
  23. import { fileURLToPath } from 'node:url';
  24. import { networkInterfaces } from 'node:os';
  25. import { launchBrowser } from './cdp.mjs';
  26. if (!process.env.HL_CHROME && existsSync('/opt/google/chrome/chrome')) process.env.HL_CHROME = '/opt/google/chrome/chrome';
  27. const HERE = dirname(fileURLToPath(import.meta.url));
  28. const APP = resolve(HERE, '..');
  29. const BIN = join(APP, 'bin/hybriel');
  30. const SCRATCH = join(APP, '.scratch');
  31. const G = join(SCRATCH, 'iplimit-gate');
  32. rmSync(G, { recursive: true, force: true });
  33. mkdirSync(G, { recursive: true });
  34. const P_MAIN = 8400, P_SHORT = 8401;
  35. const ID = `http://127.0.0.1:${P_MAIN}`;
  36. const IDS = `http://127.0.0.1:${P_SHORT}`;
  37. const SINK = join(G, 'main-mail.txt'), SINK_S = join(G, 'short-mail.txt');
  38. let failures = 0, passes = 0;
  39. function check(label, ok, detail = '') {
  40. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ' — ' + detail}`);
  41. if (ok) passes++; else failures++;
  42. }
  43. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  44. const J = JSON.stringify;
  45. const procs = [];
  46. function start(name, env) {
  47. let log = '';
  48. const p = spawn(BIN, ['project.hl'], { cwd: APP, env: { ...process.env, SMTP_HOST: '', SMTP_USER: '', SMTP_PASSWORD: '', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
  49. p.stdout.on('data', d => log += d); p.stderr.on('data', d => log += d);
  50. p.on('exit', () => writeFileSync(join(G, `${name}.log`), log));
  51. procs.push({ name, p, log: () => log });
  52. return p;
  53. }
  54. 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' });
  55. 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' });
  56. function mails(sink, email) {
  57. if (!existsSync(sink)) return [];
  58. return readFileSync(sink, 'utf8').split('\n').filter(l => l.startsWith(email + ' ')).map(l => l.split(' ')[1]);
  59. }
  60. const mailCount = (sink) => existsSync(sink) ? readFileSync(sink, 'utf8').split('\n').filter(Boolean).length : 0;
  61. // POST /api/code as a client behind nginx would arrive: `headers` are the extra ones
  62. async function code(base, email, headers = {}, raw = null) {
  63. const r = await fetch(base + '/api/code', { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: raw !== null ? raw : J({ email }) });
  64. const t = await r.text();
  65. let j = null; try { j = JSON.parse(t); } catch {}
  66. return { status: r.status, j, t };
  67. }
  68. const IPMSG = 'too many codes were requested from your network — wait a while and try again';
  69. const limited = (r) => r.status === 429 && r.j && r.j.error === IPMSG;
  70. const connected = (page, label) => page.waitFor('!!window.__hl && window.__hl.socket && window.__hl.socket.readyState === 1', { label });
  71. async function viewport(page, width, height) {
  72. await page.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 });
  73. await sleep(250);
  74. }
  75. async function shot(page, name) {
  76. const { data } = await page.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: true });
  77. writeFileSync(join(SCRATCH, `iplimit-${name}.png`), Buffer.from(data, 'base64'));
  78. }
  79. const noOverflow = (page) => page.evaluate('document.documentElement.scrollWidth <= window.innerWidth');
  80. async function askCode(page, email) {
  81. await page.waitForSelector('#email');
  82. await connected(page, 'socket before asking');
  83. await page.type('#email', email, { clear: true });
  84. await page.evaluate('document.querySelector("#message").textContent = ""');
  85. await page.click('#sendcode');
  86. // ident#20: a sent code navigates to the code page (/code) — wait for it to hydrate
  87. await page.waitFor('(/\\/code$/.test(location.pathname) && !!document.querySelector("#code")) || /\\S/.test(document.querySelector("#message").textContent)', { label: 'code page or message after ' + email });
  88. if (await page.evaluate('/\\/code$/.test(location.pathname)')) await connected(page, 'code page hydrated');
  89. }
  90. let A, B, a, b;
  91. try {
  92. for (const [name, url] of [['main', ID + '/'], ['short', IDS + '/']]) {
  93. let up = false;
  94. for (let i = 0; i < 80; i++) { try { const r = await fetch(url); if (r.ok) { up = true; break; } } catch {} await sleep(250); }
  95. if (!up) throw new Error(`${name} server did not come up\n` + procs.find(p => p.name === name).log());
  96. }
  97. // ---- the loopback bind (HL_HOST=127.0.0.1, as on Byrodin) ------------------------------
  98. const lan = Object.values(networkInterfaces()).flat().find(i => i && i.family === 'IPv4' && !i.internal);
  99. let lanReach = 'no LAN address';
  100. if (lan) { try { const r = await fetch(`http://${lan.address}:${P_MAIN}/`, { signal: AbortSignal.timeout(2000) }); lanReach = 'answered ' + r.status; } catch (e) { lanReach = 'refused'; } }
  101. check(`bind: HL_HOST=127.0.0.1 → not reachable on the LAN address (${lan && lan.address})`, lanReach === 'refused', lanReach);
  102. let lanShort = 'no LAN address';
  103. if (lan) { try { const r = await fetch(`http://${lan.address}:${P_SHORT}/`, { signal: AbortSignal.timeout(2000) }); lanShort = 'answered ' + r.status; } catch (e) { lanShort = 'refused'; } }
  104. check('bind: without HL_HOST → 0.0.0.0 as before (the LAN address answers 200)', lanShort === 'answered 200', lanShort);
  105. // ---- two IPs via the header: the limit hits one, not the other --------------------------
  106. let r;
  107. for (const n of [1, 2, 3]) {
  108. r = await code(ID, `x${n}@example.org`, { 'X-Client-IP': '203.0.113.7' });
  109. 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);
  110. }
  111. r = await code(ID, '[email protected]', { 'X-Client-IP': '203.0.113.7' });
  112. 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);
  113. r = await code(ID, '[email protected]', { 'X-Client-IP': '198.51.100.9' });
  114. 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);
  115. // ---- the header cannot be bypassed with another IP header -------------------------------
  116. const before = mailCount(SINK);
  117. 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' });
  118. check('bypass: X-Forwarded-For / CF-Connecting-IP / X-Real-IP / Forwarded beside X-Client-IP → still 429', limited(r), r.t);
  119. r = await code(ID, '[email protected]', { 'x-client-ip': '203.0.113.7' });
  120. check('bypass: the header in lower case is the same header → 429', limited(r), r.t);
  121. r = await code(ID, '[email protected]', { 'X-Client-IP': ' 203.0.113.7 ' });
  122. check('bypass: surrounding blanks are trimmed → 429', limited(r), r.t);
  123. check('bypass: nothing was mailed by the refused requests', mailCount(SINK) === before, String(mailCount(SINK) - before));
  124. // ---- no X-Client-IP (dev, no nginx): ONE shared bucket, other headers ignored ----------
  125. const direct = [];
  126. 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']]) {
  127. direct.push(await code(ID, `d${n}@example.org`, { 'X-Forwarded-For': fwd, 'CF-Connecting-IP': fwd }));
  128. }
  129. 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)));
  130. 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);
  131. r = await code(ID, '[email protected]', { 'X-Client-IP': '' });
  132. check('an EMPTY X-Client-IP counts as no header (shared bucket) → 429', limited(r), r.t);
  133. // ---- IPv6: one bucket per /64; IPv4-mapped = the IPv4 address ------------------------
  134. const v6 = [];
  135. 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']]) {
  136. v6.push(await code(ID, `v${n}@example.org`, { 'X-Client-IP': ip }));
  137. }
  138. 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)));
  139. check('ipv6: a 4th address of the same /64 → 429', limited(v6[3]), v6[3].t);
  140. r = await code(ID, '[email protected]', { 'X-Client-IP': '2001:db8:aa:2::1' });
  141. check('ipv6: the next /64 is another bucket → 200', r.status === 200, r.t);
  142. r = await code(ID, '[email protected]', { 'X-Client-IP': '::ffff:203.0.113.7' });
  143. check('ipv4-mapped ipv6 ::ffff:203.0.113.7 = 203.0.113.7 → 429', limited(r), r.t);
  144. // ---- the per-address limit still applies (3 per address, across IPs) --------------------
  145. const per = [];
  146. for (const n of [1, 2, 3, 4]) per.push(await code(ID, '[email protected]', { 'X-Client-IP': `198.18.0.${n}` }));
  147. 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)));
  148. 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);
  149. // ---- the route itself ---------------------------------------------------------------
  150. r = await fetch(ID + '/api/code');
  151. check('GET /api/code → 405', r.status === 405, String(r.status));
  152. r = await code(ID, null, { 'X-Client-IP': '198.19.0.1' }, '{"email":"[email protected]","ip":"1.2.3.4"}');
  153. check('strict body: an unknown field (ip) → 400 naming it', r.status === 400 && r.j.error === 'unknown field: ip', r.t);
  154. r = await code(ID, null, { 'X-Client-IP': '198.19.0.1' }, '{bad');
  155. check('invalid JSON → 400, no source path', r.status === 400 && /not valid JSON/.test(r.j.error) && !/\.hl/.test(r.t), r.t);
  156. r = await code(ID, 'not-an-address', { 'X-Client-IP': '198.19.0.1' });
  157. check('not an address → 400', r.status === 400 && r.j.error === 'that is not an email address', r.t);
  158. 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]'] }) });
  159. const fj = await fr.json();
  160. 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));
  161. // ---- the long window (day limit 5) and the short window's expiry (2 s) ------------------
  162. const s = [];
  163. for (const n of [1, 2, 3, 4]) s.push(await code(IDS, `s${n}@example.org`, { 'X-Client-IP': '192.0.2.50' }));
  164. 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)));
  165. await sleep(2200);
  166. 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' })];
  167. 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)));
  168. await sleep(2200);
  169. r = await code(IDS, '[email protected]', { 'X-Client-IP': '192.0.2.50' });
  170. 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);
  171. r = await code(IDS, '[email protected]', { 'X-Client-IP': '192.0.2.51' });
  172. check('short server: another IP is unaffected → 200', r.status === 200, r.t);
  173. // ---- real browsers: one IP each (nginx's header, set per browser) ----------------------
  174. A = await launchBrowser({ debugPortRange: [8720, 8729] });
  175. B = await launchBrowser({ debugPortRange: [8730, 8739] });
  176. a = await A.newPage();
  177. b = await B.newPage();
  178. for (const [pg, ip] of [[a, '100.64.0.1'], [b, '100.64.0.2']]) {
  179. await pg.send('Network.enable');
  180. await pg.send('Network.setExtraHTTPHeaders', { headers: { 'X-Client-IP': ip } });
  181. }
  182. await a.goto(ID + '/');
  183. for (const n of [1, 2, 3]) {
  184. await askCode(a, `ba${n}@example.org`);
  185. const ok = await a.evaluate('!!document.querySelector("#code")');
  186. 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'));
  187. await a.click('#back');
  188. await a.waitForSelector('#email');
  189. }
  190. await askCode(a, '[email protected]');
  191. const shown = await a.text('#message');
  192. 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);
  193. await viewport(a, 390, 844); await shot(a, 'refused-390');
  194. check('layout: 390px refusal has no horizontal overflow', await noOverflow(a));
  195. await viewport(a, 1280, 900); await shot(a, 'refused-1280');
  196. check('layout: 1280px refusal has no horizontal overflow', await noOverflow(a));
  197. await b.goto(ID + '/');
  198. await askCode(b, '[email protected]');
  199. 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'));
  200. const bc = mails(SINK, '[email protected]').at(-1);
  201. await b.type('#code', bc, { clear: true });
  202. await b.click('#verify');
  203. await b.waitFor('!!document.querySelector("#identityform") || !!document.querySelector("#identities")', { label: 'B signed in' });
  204. 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'));
  205. await b.goto(ID + '/');
  206. await b.waitForSelector('#identities');
  207. check('browser B: reload keeps the session (identsid cookie over the pinned listener)', (await b.text('#meemail')) === '[email protected]');
  208. // the refused fetch is a 429: Chrome logs "Failed to load resource … 429" — expected
  209. const expected = /favicon|status of 429 .*\/api\/code$/;
  210. const problems = [...a.problems(), ...b.problems()].filter(m => !expected.test(m.text));
  211. check('browsers: no console errors or warnings (besides the expected 429 line)', problems.length === 0, J(problems.slice(0, 5)));
  212. } catch (e) {
  213. failures++;
  214. console.log('FAIL (aborted) ' + (e && e.stack || e));
  215. for (const [n, pg] of [['A', a], ['B', b]]) if (pg) console.log(`console ${n}: ` + J(pg.messages.slice(-8)));
  216. } finally {
  217. for (const br of [A, B]) { if (br) { try { await br.close(); } catch {} } }
  218. for (const { p } of procs) { try { p.kill('SIGTERM'); } catch {} }
  219. await sleep(500);
  220. for (const { p } of procs) { if (p.exitCode === null && p.signalCode === null) { try { p.kill('SIGKILL'); } catch {} } }
  221. await sleep(200);
  222. console.log(`\n${passes} passed, ${failures} failed`);
  223. process.exit(failures ? 1 : 0);
  224. }

Branches

Latest commits

  • 5fdbb6b2ident mission 009: report — scratch folder notemre
  • 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
  • fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
  • d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
  • 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
  • f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
  • ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 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