http-attack 0.0.0-stage → 1.0.1-rc.0

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.
@@ -0,0 +1,139 @@
1
+ import { request } from 'node:http';
2
+ import { connect } from 'node:net';
3
+
4
+ export const is2xx = (s) => s >= 200 && s < 300;
5
+ export const is4xx = (s) => s >= 400 && s < 500;
6
+ export const is5xx = (s) => s >= 500;
7
+
8
+ // A login page is a Location with a whole path segment that names one: /login, /accounts/sign-in, /auth/start. Segments are
9
+ // compared whole, so /authors/3 and /processors are not login pages.
10
+ const LOGIN_SEGMENTS = new Set(['login', 'log-in', 'log_in', 'logon', 'signin', 'sign-in', 'sign_in', 'sso', 'auth', 'authenticate']);
11
+ export const isLoginLocation = (location) => {
12
+ if (!location) return false;
13
+ try {
14
+ return new URL(location, 'http://local').pathname.split('/').some((segment) => LOGIN_SEGMENTS.has(decodeURIComponent(segment).toLowerCase().replace(/\.[a-z]+$/, '')));
15
+ } catch { return false; }
16
+ };
17
+
18
+ // Access was granted: a 2xx, or a redirect that does not lead to a login page (a 303 to the created item is a grant).
19
+ export const granted = (res) => is2xx(res.s) || (res.s >= 300 && res.s < 400 && !isLoginLocation(res.headers?.location));
20
+
21
+ const BODY_CAP = 200_000;
22
+ const REQUEST_TIMEOUT_MS = 10_000;
23
+ const RAW_TIMEOUT_MS = 3000;
24
+ const STREAM_TICK_MS = 5;
25
+
26
+ const ID_SEGMENT = /\/(?:\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{12,})(?=\/|$)/gi;
27
+
28
+ const ALWAYS_CREDENTIAL =['authorization', 'cookie', 'x-api-key'];
29
+
30
+ const bareHost = (hostname) => hostname.replace(/^\[|\]$/g, '');
31
+
32
+ // Everything that talks to the app. `base` is a URL; `opts` carries the credentials and the common-header list.
33
+ export function createClient(base, opts) {
34
+ const host = bareHost(base.hostname);
35
+ const port = Number(base.port || 80);
36
+ const credentialNames = [...ALWAYS_CREDENTIAL, ...opts.authHeaderNames];
37
+ const withoutCredentials = (h) => Object.fromEntries(Object.entries(h).filter(([k]) => !credentialNames.includes(k)));
38
+ const misses = new Map(); // "METHOD path" => { headers, statuses }: responses that lack a common header
39
+
40
+ const noteHeaders = (method, path, status, headers) => {
41
+ const missing = opts.common.filter((h) => headers?.[h] === undefined);
42
+ if (!missing.length) return;
43
+ const key = `${method} ${path.split('?')[0]}`;
44
+ const entry = misses.get(key) ?? { method, path: path.split('?')[0], headers: new Set(), statuses: new Set() };
45
+ missing.forEach((h) => entry.headers.add(h));
46
+ entry.statuses.add(status);
47
+ misses.set(key, entry);
48
+ };
49
+
50
+ // The misses grouped by route, so one defect is one id however many ids the paths carried: a path that fits the item
51
+ // template is that template, else numeric, UUID and long hex segments become {id}.
52
+ const missesByRoute = (itemTemplate) => {
53
+ const fits = itemTemplate && new RegExp(`^${itemTemplate.split('?')[0].split(/\{[^}]*\}/).map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]+')}$`);
54
+ const routeOf = ({ method, path }) => (fits?.test(path) ? `${method} ${itemTemplate.split('?')[0]}` : `${method} ${path.replace(ID_SEGMENT, '/{id}')}`);
55
+ const routes = new Map();
56
+ for (const entry of misses.values()) {
57
+ const route = routeOf(entry);
58
+ const group = routes.get(route) ?? { headers: new Set(), statuses: new Set() };
59
+ entry.headers.forEach((h) => group.headers.add(h));
60
+ entry.statuses.forEach((s) => group.statuses.add(s));
61
+ routes.set(route, group);
62
+ }
63
+ return routes;
64
+ };
65
+
66
+ // node:http, because fetch cannot send a path starting with // nor stream a slow body.
67
+ // auth: 'a' (the default headers), 'none' (credentials removed), 'b' (the other user's credentials),
68
+ // 'cookie' (only the session cookie, as a browser sends it: no token or API key header).
69
+ const send = (method, path, { body, type, headers = {}, auth = 'a', common = true } = {}) => new Promise((done) => {
70
+ const data = body === undefined ? null : Buffer.isBuffer(body) ? body : Buffer.from(body);
71
+ const cookie = opts.headers.cookie === undefined ? {} : { cookie: opts.headers.cookie };
72
+ const creds = auth === 'b' ? { ...withoutCredentials(opts.headers), ...opts.otherHeaders }
73
+ : auth === 'none' ? withoutCredentials(opts.headers)
74
+ : auth === 'cookie' ? { ...withoutCredentials(opts.headers), ...cookie } : opts.headers;
75
+ const all = { ...creds, ...(data ? { 'content-length': data.length, ...(type ? { 'content-type': type } : {}) } : {}), ...headers };
76
+ let settled = false;
77
+ const settle = (v) => { if (!settled) { settled = true; done(v); } };
78
+ let answered = false;
79
+ const req = request({ hostname: host, port, path, method, agent: false,
80
+ headers: Object.fromEntries(Object.entries(all).filter(([, v]) => v !== undefined)) }, (res) => {
81
+ answered = true;
82
+ let text = '';
83
+ res.setEncoding('utf8');
84
+ res.on('data', (d) => { if (text.length < BODY_CAP) text += d; });
85
+ const result = (extra) => ({ s: res.statusCode, headers: res.headers, text, ...extra });
86
+ res.on('end', () => { if (common) noteHeaders(method, path, res.statusCode, res.headers); settle(result()); });
87
+ // Headers sent, then the connection cut: the answer is the status that was seen, flagged incomplete.
88
+ res.on('aborted', () => settle(result({ truncated: true, err: 'ECONNRESET' })));
89
+ res.on('close', () => settle(result({ truncated: !res.complete, err: res.complete ? undefined : 'ECONNRESET' })));
90
+ });
91
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => req.destroy(new Error('timeout')));
92
+ // Once the status line is in, a write error (EPIPE, ECONNRESET) is the server closing after answering early,
93
+ // as Node does for a request with "Connection: close": the response events decide the outcome.
94
+ req.on('error', (e) => answered || settle({ s: null, err: e.code ?? e.message, headers: {}, text: '' }));
95
+ req.end(data ?? undefined);
96
+ });
97
+
98
+ const alive = async () => (await send('GET', '/', { common: false })).s !== null;
99
+
100
+ // A body that keeps coming: a server without a limit or a timeout reads it forever.
101
+ const stream = (method, path, headers, total, pieceSize, { stopAfterMs, prefix }) => new Promise((done) => {
102
+ const req = request({ hostname: host, port, path, method, agent: false, headers: { ...opts.headers, ...headers } });
103
+ const piece = Buffer.alloc(pieceSize, 97);
104
+ const t0 = Date.now();
105
+ let sent = 0, over = false, timer;
106
+ const finish = (v) => { if (over) return; over = true; clearInterval(pump); clearTimeout(timer); req.destroy(); done({ ...v, sent, ms: Date.now() - t0 }); };
107
+ const pump = setInterval(() => {
108
+ if (sent >= total) { clearInterval(pump); timer = setTimeout(() => finish({ kind: 'silent' }), stopAfterMs); return; }
109
+ if (req.writableNeedDrain) return;
110
+ req.write(sent === 0 && prefix ? Buffer.concat([Buffer.from(prefix), piece]) : piece);
111
+ sent += piece.length;
112
+ }, STREAM_TICK_MS);
113
+ req.on('response', (res) => { res.resume(); finish({ kind: 'status', s: res.statusCode, headers: res.headers }); });
114
+ req.on('error', (e) => finish({ kind: 'closed', code: e.code ?? e.message }));
115
+ req.on('close', () => finish({ kind: 'closed', code: 'closed' }));
116
+ });
117
+
118
+ // Raw requests node:http refuses to send. Most servers answer them from the parser, before any middleware.
119
+ const raw = (payload) => new Promise((done) => {
120
+ const socket = connect({ host, port });
121
+ let data = Buffer.alloc(0), over = false;
122
+ const parse = (t) => {
123
+ const lines = t.split('\r\n');
124
+ const s = Number(lines[0].match(/^HTTP\/\d(?:\.\d)? (\d{3})/)?.[1]);
125
+ const headers = Object.fromEntries(lines.slice(1).filter((l) => l.includes(':'))
126
+ .map((l) => [l.slice(0, l.indexOf(':')).trim().toLowerCase(), l.slice(l.indexOf(':') + 1).trim()]));
127
+ return Number.isInteger(s) ? { kind: 'status', s, headers } : { kind: 'garbled', text: lines[0].slice(0, 40) };
128
+ };
129
+ const finish = (v) => { if (over) return; over = true; clearTimeout(timer); socket.destroy(); done(v); };
130
+ const timer = setTimeout(() => finish({ kind: 'silent' }), RAW_TIMEOUT_MS);
131
+ socket.on('connect', () => socket.write(payload));
132
+ socket.on('data', (d) => { data = Buffer.concat([data, d]); const i = data.indexOf('\r\n\r\n'); if (i >= 0) finish(parse(data.subarray(0, i).toString('latin1'))); });
133
+ socket.on('error', (e) => finish(data.length ? parse(data.toString('latin1')) : { kind: 'closed', code: e.code }));
134
+ socket.on('close', () => finish(data.length ? parse(data.toString('latin1')) : { kind: 'closed' }));
135
+ });
136
+
137
+ return { base, host, port, send, alive, stream, raw, misses, missesByRoute, withoutCredentials, noteHeaders,
138
+ hasCredentials: () => Object.keys(opts.headers).some((k) => credentialNames.includes(k)) };
139
+ }
@@ -0,0 +1,159 @@
1
+ import { granted, is2xx } from '../http.mjs';
2
+ import { wire } from '../body.mjs';
3
+
4
+ const snippet = (text) => text.replace(/\s+/g, ' ').slice(0, 120);
5
+
6
+ // Where a created item can be read back: the Location, else --item-path with the id from the JSON body.
7
+ export function itemUrlOf(res, { base, itemPath }) {
8
+ if (res.headers.location) {
9
+ try { const u = new URL(res.headers.location, base); return u.pathname + u.search; } catch { /* not a URL: fall through */ }
10
+ }
11
+ if (!itemPath) return null;
12
+ try {
13
+ const j = JSON.parse(res.text);
14
+ const id = j?.id ?? j?.data?.id ?? j?.item?.id;
15
+ return id === undefined ? null : itemPath.replace('{id}', encodeURIComponent(id));
16
+ } catch { return null; }
17
+ }
18
+
19
+ // A stable label for an item URL: the id segment becomes {id}.
20
+ // The last path segment is the id, with or without a trailing slash: /items/37, /items/37/, /api/v1/items/37.
21
+ const withIdSegment = (url) => url.split('?')[0].replace(/[^/]+(\/?)$/, '{id}$1');
22
+ const lastSegment = (url) => url.split('?')[0].split('/').filter(Boolean).pop();
23
+ export const itemLabel = (url, template) => (template && url.startsWith(template.split('{id}')[0]) ? template : withIdSegment(url));
24
+
25
+ // The id-less template of an item route, learned from a created item's Location when --item-path was not given.
26
+ export const inferTemplate = (url, res) => {
27
+ const last = lastSegment(url);
28
+ let id;
29
+ try { id = JSON.parse(res.text)?.id; } catch { /* no JSON body */ }
30
+ return last && (id === undefined || decodeURIComponent(last) === String(id)) ? withIdSegment(url) : null;
31
+ };
32
+
33
+ // The legitimate create every other probe of this write depends on. { ok, res, item }.
34
+ export async function legitWrite(run, w) {
35
+ const target = `${w.method} ${w.path}`;
36
+ const spec = { group: 'auth', probe: 'legit-write', target, name: `${target} legitimate request` };
37
+ const st = run.settings(w.method, w.path);
38
+ const p = wire(w, st);
39
+ if (p.error) { run.notRun(spec, p.error); return { ok: false }; }
40
+ const res = await run.client.send(w.method, w.path, { body: p.text, type: p.type });
41
+ if (await run.transportFault(res, spec, '2xx')) return { ok: false };
42
+ if (!granted(res)) {
43
+ run.notRun(spec, res.s >= 300
44
+ ? `the legitimate request was redirected to login (${res.headers.location}): the credentials do not work, so every other row of this write is unreliable`
45
+ : `the legitimate request answered ${res.s} (${snippet(res.text)}): every other row of this write is unreliable until it succeeds`);
46
+ return { ok: false, res };
47
+ }
48
+ run.pass(spec, res.s, '2xx');
49
+ return { ok: true, res, item: itemUrlOf(res, { base: run.client.base, itemPath: run.itemPath }) };
50
+ }
51
+
52
+ // A group of probes that all depend on a legitimate write that failed.
53
+ export function notRunDependents(run, w, why) {
54
+ const target = `${w.method} ${w.path}`;
55
+ for (const [group, probe] of [['input', 'input-probes'], ['resources', 'resource-probes'], ['auth', 'access-probes'], ['csrf', 'csrf-probes']]) {
56
+ if (group === 'csrf' && !run.opts.csrf) continue;
57
+ run.notRun({ group, probe, target, name: `${target} ${probe}` }, `the legitimate request failed: ${why}`);
58
+ }
59
+ }
60
+
61
+ const unauthenticated = async (run, spec, method, path, expected, payload = {}) => {
62
+ const res = await run.client.send(method, path, { auth: 'none', ...payload });
63
+ if (await run.transportFault(res, spec, expected)) return;
64
+ if (granted(res)) run.fail('unauthenticated-access', spec, res.s, expected, res.s >= 300 ? `redirected to ${res.headers.location ?? '(no Location)'}, not to a login page` : undefined);
65
+ else run.pass(spec, res.s, expected);
66
+ };
67
+
68
+ // Authentication and object ownership on the item a legitimate write created.
69
+ export async function accessProbes(run, w, legit) {
70
+ const { client, opts } = run;
71
+ const target = `${w.method} ${w.path}`;
72
+ const spec = (probe, name, t = target) => ({ group: 'auth', probe, target: t, name });
73
+ const st = run.settings(w.method, w.path);
74
+ const label = legit.item ? itemLabel(legit.item, run.itemTemplate) : null;
75
+
76
+ if (client.hasCredentials()) {
77
+ if (st.public) run.skipped(spec('write-without-credentials', `${target} without credentials`), 'the route is configured public');
78
+ else {
79
+ const p = wire(w, st);
80
+ await unauthenticated(run, spec('write-without-credentials', `${target} without credentials`), w.method, w.path, '401 (or a redirect to login)', { body: p.text, type: p.type });
81
+ }
82
+ if (legit.item) {
83
+ const itemSpec = spec('item-without-credentials', `GET ${label} without credentials`, `GET ${label}`);
84
+ if (run.settings('GET', legit.item).public) run.skipped(itemSpec, 'the item route is configured public');
85
+ else await unauthenticated(run, itemSpec, 'GET', legit.item, '401 (or a redirect to login)');
86
+ }
87
+ } else if (!run.state.noCredentialsReported) {
88
+ run.state.noCredentialsReported = true;
89
+ run.notRun(spec('no-credentials', 'authentication probes', '*'),
90
+ 'no credentials were given (--header, --auth-env, --auth-header-name): the rows without credentials prove nothing. Suppress this row if the API is open by design');
91
+ }
92
+
93
+ const idor = spec('idor', `${target} ownership`);
94
+ if (!Object.keys(opts.otherHeaders).length) { run.notRun(idor, 'no credentials for user B (--other-header or --other-auth-env): the ownership probes were skipped'); return; }
95
+ if (!legit.item) { run.notRun(idor, `the create answered no Location${opts.itemPath || run.itemPath ? ' and its body has no id for --item-path' : ' and --item-path was not given'}`); return; }
96
+ const own = await client.send('GET', legit.item);
97
+ if (!is2xx(own.s)) { run.notRun(idor, `user A cannot read the item it created (${own.s ?? own.err}): the ownership probes would prove nothing`); return; }
98
+ const p = wire(w, st);
99
+ const bCreate = await client.send(w.method, w.path, { body: p.text, type: p.type, auth: 'b' });
100
+ const bItem = bCreate.s !== null && granted(bCreate) ? itemUrlOf(bCreate, { base: client.base, itemPath: run.itemPath }) : null;
101
+ const bOwn = bItem ? await client.send('GET', bItem, { auth: 'b' }) : null;
102
+ if (!bOwn || !is2xx(bOwn.s)) {
103
+ run.notRun(idor, `user B's credentials cannot create and read their own item (create ${bCreate.s ?? bCreate.err}${bOwn ? `, read ${bOwn.s}` : ''}): a 401/403 below would be a bad token, not ownership`);
104
+ return;
105
+ }
106
+ for (const m of ['GET', 'DELETE']) {
107
+ const s = spec(`idor-${m.toLowerCase()}`, `${m} ${label} as another user (IDOR / BOLA)`, `${m} ${label}`);
108
+ const r = await client.send(m, legit.item, { auth: 'b' });
109
+ const expected = '404 (or 403)';
110
+ if (await run.transportFault(r, s, expected)) continue;
111
+ if (!granted(r)) { run.pass(s, r.s, expected); continue; }
112
+ if (m === 'DELETE') {
113
+ // A 2xx proves nothing unless the object is gone: some routes answer 2xx to any method and delete nothing.
114
+ const after = await client.send('GET', legit.item);
115
+ if (is2xx(after.s)) { run.pass(s, r.s, expected, `answered ${r.s} but the item still exists: nothing was deleted`); continue; }
116
+ }
117
+ run.fail('idor', s, r.s, expected, "another user's credentials reach this object");
118
+ }
119
+ }
120
+
121
+ export async function readsWithoutCredentials(run) {
122
+ const { client, opts } = run;
123
+ if (!client.hasCredentials()) return;
124
+ for (const r of opts.reads) {
125
+ const spec = { group: 'auth', probe: 'read-without-credentials', target: `GET ${r.split('?')[0]}`, name: `GET ${r} without credentials` };
126
+ if (run.settings('GET', r).public) run.skipped(spec, 'the route is configured public');
127
+ else await unauthenticated(run, spec, 'GET', r, '401 (or a redirect to login)');
128
+ }
129
+ }
130
+
131
+ // Cookie-authenticated apps: a write from another origin must be refused (CSRF). "Same site" includes another port.
132
+ export async function csrfProbes(run, w) {
133
+ const { client } = run;
134
+ const st = run.settings(w.method, w.path);
135
+ if (!Object.hasOwn(run.opts.headers, 'cookie')) {
136
+ run.notRun({ group: 'csrf', probe: 'cookie-required', target: `${w.method} ${w.path}`, name: `${w.method} ${w.path} CSRF probes` },
137
+ '--csrf tests cookie authentication and no Cookie header was given: a browser does not attach a Bearer token to a cross-site request. Pass the session cookie with --header "Cookie: ..."');
138
+ return;
139
+ }
140
+ const origins = [
141
+ ['another-site', 'from another site', { origin: 'https://attacker.example', 'sec-fetch-site': 'cross-site' }],
142
+ ['another-port', 'from another port of the same host', { origin: `http://${client.base.hostname}:${client.port + 1}`, 'sec-fetch-site': 'same-site' }],
143
+ ['null-origin', 'with Origin: null', { origin: 'null' }],
144
+ ];
145
+ for (const [tag, label, headers] of origins) {
146
+ const target = `${w.method} ${w.path}`;
147
+ const s = { group: 'csrf', probe: `origin-${tag}`, target, name: `${target} ${label}` };
148
+ const p = wire(w, st);
149
+ const attempt = () => client.send(w.method, w.path, { body: p.text, type: p.type, headers, auth: 'cookie' });
150
+ // A server that refuses early may reset the connection while the body is still being sent: one repeat before a drop is judged.
151
+ let r = await attempt();
152
+ if (r.s === null || r.truncated) r = await attempt();
153
+ const expected = '403 (any 4xx): the Origin is not this app';
154
+ if (await run.transportFault(r, s, expected)) continue;
155
+ if (granted(r)) run.fail('csrf', s, r.s, expected, r.s >= 300 ? `redirected to ${r.headers.location ?? '(no Location)'}: the write was accepted` : undefined);
156
+ else if (r.s === 409) run.notRun(s, 'answered 409: the body was refused before the origin check, so the check was not reached');
157
+ else run.pass(s, r.s, expected);
158
+ }
159
+ }
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ import { wire } from '../body.mjs';
5
+ import { leaksInternals } from '../run.mjs';
6
+
7
+ const COMMAND_TIMEOUT_MS = 30_000;
8
+ const RECOVERY_MS = 300;
9
+ const SQLITE_BUSY_TIMEOUT_MS = 3000;
10
+
11
+ // Experimental on some Node versions: the warning is not the user's concern.
12
+ const importSqlite = async () => {
13
+ const emit = process.emitWarning;
14
+ process.emitWarning = (w, ...rest) => (String(w?.message ?? w).includes('SQLite') ? undefined : emit.call(process, w, ...rest));
15
+ try { return await import('node:sqlite'); } catch { return null; } finally { process.emitWarning = emit; }
16
+ };
17
+
18
+ const runShell = (command) => spawnSync(command, { shell: true, encoding: 'utf8', timeout: COMMAND_TIMEOUT_MS });
19
+
20
+ // Fault injection: the database fails while a legitimate write arrives. Expected: one 5xx with the generic error body,
21
+ // and the process stays up. Two injectors: an exclusive lock on a SQLite file, or any shell command with its restore.
22
+ export async function faultProbe(run, w) {
23
+ const { client, opts } = run;
24
+ const how = opts.faultSqlite ? 'the SQLite file locked (BEGIN EXCLUSIVE)' : 'the configured fault command run'; // the command text can hold secrets: it stays out of reports
25
+ const target = w ? `${w.method} ${w.path}` : 'write';
26
+ const spec = { group: 'faults', probe: 'database-fault', target, name: `${target} with ${how}` };
27
+ const expected = '503 or 500 with the generic error body and the common headers; the process keeps answering';
28
+ if (!w) return run.notRun(spec, opts.writes.length ? 'no legitimate write succeeded: the fault probe sends one while the database is broken, so it has nothing to send' : 'a fault probe needs a --write');
29
+ const st = run.settings(w.method, w.path);
30
+
31
+ let release = () => {};
32
+ if (opts.faultSqlite) {
33
+ const sqlite = await importSqlite();
34
+ if (!sqlite) return run.notRun(spec, 'node:sqlite is not available (Node 22.13+)');
35
+ const file = resolve(opts.faultSqlite);
36
+ if (!existsSync(file)) return run.notRun(spec, `looked for the database at "${file}" (from "${opts.faultSqlite}") and it does not exist: pass the app the same absolute path`);
37
+ const db = new sqlite.DatabaseSync(file, { timeout: SQLITE_BUSY_TIMEOUT_MS });
38
+ try { db.exec('BEGIN EXCLUSIVE'); } catch (e) { db.close(); return run.notRun(spec, `could not lock the database: ${e.message}`); }
39
+ release = () => { try { db.exec('ROLLBACK'); } catch { /* the lock was already gone */ } db.close(); };
40
+ } else {
41
+ const started = runShell(opts.faultCmd);
42
+ if (started.status !== 0) {
43
+ if (opts.faultRestore) runShell(opts.faultRestore);
44
+ return run.notRun(spec, `faultCmd failed (${started.error?.code ?? `exit ${started.status}`}): ${(started.stderr || started.stdout || '').trim().slice(0, 200)}`);
45
+ }
46
+ release = () => {
47
+ if (!opts.faultRestore) return;
48
+ const restored = runShell(opts.faultRestore);
49
+ if (restored.status !== 0) process.stderr.write(`faultRestore failed (exit ${restored.status}): the fault may still be active.\n`);
50
+ };
51
+ }
52
+
53
+ let res;
54
+ try { const p = wire(w, st); res = await client.send(w.method, w.path, { body: p.text, type: p.type }); } finally { release(); }
55
+ await new Promise((r) => setTimeout(r, RECOVERY_MS));
56
+ if (!(await client.alive())) run.fail('process-died', spec, res.s ?? res.err, expected, 'the process died: an error from the database escaped a handler (unhandled rejection / uncaught exception)');
57
+ else if (res.s === null || res.truncated) run.fail('connection-dropped', spec, res.err, expected, 'the connection was dropped instead of an error response');
58
+ else if (res.s < 500) run.fail('fault-not-reported', spec, res.s, expected, 'a failed write was reported as something else');
59
+ else if (leaksInternals(res.text)) run.fail('stack-leak', spec, res.s, expected, 'the 5xx body leaks internals (stack, driver error)');
60
+ else run.pass(spec, res.s, expected);
61
+ }
@@ -0,0 +1,104 @@
1
+ import { granted } from '../http.mjs';
2
+ import { caseVariant, isJsonBody, splitAtField, stringFields, withField, withValueAt, wire } from '../body.mjs';
3
+
4
+ const MIB = 1 << 20;
5
+ const ACCEPTED = (s) => s >= 200 && s < 400;
6
+
7
+ // Malformed and hostile bodies on one write. Every skipped probe is a NOT RUN row.
8
+ export async function malformedInput(run, w) {
9
+ const { client } = run;
10
+ const target = `${w.method} ${w.path}`;
11
+ const spec = (probe, name) => ({ group: 'input', probe, target, name: `${target} ${name}` });
12
+ const st = run.settings(w.method, w.path);
13
+ const json = isJsonBody(w);
14
+ const p = wire(w, st);
15
+ const type = p.type;
16
+ const send = (method, body, headers, t = type) => client.send(method, w.path, { body, type: t, headers });
17
+
18
+ // The answer must be a 4xx: a 5xx or a dropped connection is recorded as such, a 2xx/3xx as accepted input.
19
+ const mustRefuse = async (s, res, expected, rule = 'accepted-invalid') => {
20
+ if (await run.transportFault(res, s, expected)) return;
21
+ if (ACCEPTED(res.s)) run.fail(rule, s, res.s, expected);
22
+ else run.pass(s, res.s, expected);
23
+ };
24
+
25
+ if (json) {
26
+ await mustRefuse(spec('malformed-json', 'malformed JSON'), await send(w.method, '{"x":'), '400 with the error body');
27
+
28
+ const split = splitAtField(p);
29
+ const s = spec('invalid-utf8', 'invalid UTF-8 inside a string');
30
+ if (!split) run.notRun(s, 'the body has no string field to put the invalid bytes in');
31
+ else {
32
+ const bytes = Buffer.concat([Buffer.from(split[0]), Buffer.from([0xc3, 0x28, 0xff]), Buffer.from(split[1])]);
33
+ const res = await send(w.method, bytes);
34
+ if (st.invalidUtf8 === 'replace') {
35
+ if (!(await run.transportFault(res, s, 'any non-5xx: the route replaces invalid bytes with U+FFFD'))) run.pass(s, res.s, 'any non-5xx (invalidUtf8: replace)');
36
+ } else await mustRefuse(s, res, '400: strict UTF-8 decoding, nothing stored with U+FFFD (set invalidUtf8: "replace" for a route that replaces by design)');
37
+ }
38
+
39
+ const noType = spec('no-content-type', 'without a Content-Type');
40
+ const res = await client.send(w.method, w.path, { body: wire(w, st).text, headers: { 'content-type': undefined } });
41
+ if (!(await run.transportFault(res, noType, '415 (never a 5xx from reading a missing header)'))) run.pass(noType, res.s, '415 (never a 5xx from reading a missing header)');
42
+
43
+ const variantSpec = spec('media-type-variant', `${caseVariant(type)}`);
44
+ const variant = await send(w.method, wire(w, st).text, undefined, caseVariant(type));
45
+ if (!(await run.transportFault(variant, variantSpec, 'accepted: media types are case-insensitive and may carry parameters (RFC 9110 8.3.1)'))) {
46
+ if (variant.s === 415) run.fail('media-type-strict', variantSpec, 415, 'accepted: media types are case-insensitive and may carry parameters (RFC 9110 8.3.1)');
47
+ else run.pass(variantSpec, variant.s, 'accepted');
48
+ }
49
+
50
+ const fields = stringFields(wire(w, st));
51
+ if (!fields.length) run.notRun(spec('wrong-type', 'string field sent as another type'), 'the body has no string field to replace with another type');
52
+ for (const path of fields) {
53
+ const name = path.join('.');
54
+ for (const [label, tag, value] of [['a number', 'number', 1], ['null', 'null', null], ['an array', 'array', ['a']], ['an object', 'object', { a: 1 }]]) {
55
+ await mustRefuse(spec(`wrong-type-${tag}-${name}`, `"${name}" sent as ${label}`), await send(w.method, withValueAt(wire(w, st), path, value)), '400/422: wrong type, never coerced, never a 5xx');
56
+ }
57
+ }
58
+ } else run.notRun(spec('json-probes', 'JSON probes'), 'the write body is not JSON: malformed JSON, invalid UTF-8, media type and wrong-type probes need a JSON body');
59
+
60
+ // Only a 413 shows the limit works. A 400/422 is the validation of the field the probe padded: the limit was not tested.
61
+ // A server that answers early and closes can reset a large upload: the request is repeated once before a drop is judged.
62
+ // `makeBody` builds a new wire body (new unique values) for each attempt: a dropped attempt may already have been stored,
63
+ // and repeating it would answer 409 and hide the missing limit.
64
+ const limitVerdict = async (s, makeBody, expected, rule) => {
65
+ let res = await send(w.method, makeBody());
66
+ if (res.s === null || res.truncated) res = await send(w.method, makeBody());
67
+ if (await run.transportFault(res, s, expected)) return;
68
+ if (res.s === 413) run.pass(s, res.s, expected);
69
+ else if (granted(res)) run.fail(rule, s, res.s, expected, rule === 'limit-counts-characters' ? `${res.s} for a body over the limit` : undefined);
70
+ else run.notRun(s, `answered ${res.s}: the body was refused by validation (or another check) before a size limit, so the limit was not tested`);
71
+ };
72
+
73
+ const big = spec('body-1mb', '1 MB body');
74
+ if (st.bodyLimit && st.bodyLimit >= MIB) run.skipped(big, `bodyLimit ${st.bodyLimit} is at least 1 MB`);
75
+ else {
76
+ const makeBody = () => withField(wire(w, st), (v) => v + 'a'.repeat(MIB));
77
+ if (makeBody() === null) run.notRun(big, 'the body has no string field to pad to 1 MB');
78
+ else await limitVerdict(big, makeBody, '413, never stored and never a 5xx', 'no-body-limit');
79
+ }
80
+
81
+ const bytesProbe = spec('limit-counts-bytes', 'body 1+ byte over the limit made of multibyte characters');
82
+ const bigRow = run.rows.findLast((r) => r.probe === 'body-1mb' && r.target === target);
83
+ if (!json) { /* the JSON probes above already reported why */ }
84
+ else if (!st.bodyLimit) run.skipped(bytesProbe, 'no body limit configured (--body-limit or bodyLimit)');
85
+ else if (bigRow?.rule === 'no-body-limit') run.skipped(bytesProbe, 'the 1 MB body was accepted: there is no limit to count bytes against');
86
+ else {
87
+ const makeBody = () => {
88
+ const q = wire(w, st);
89
+ const rest = Buffer.byteLength(withField(q, () => '') ?? '');
90
+ return withField(q, () => 'é'.repeat(Math.floor((st.bodyLimit - rest) / 2) + 1));
91
+ };
92
+ if (makeBody() === null) run.notRun(bytesProbe, 'the body has no string field to pad with multibyte characters');
93
+ else await limitVerdict(bytesProbe, makeBody, '413: the limit counts bytes, not characters', 'limit-counts-characters');
94
+ }
95
+
96
+ for (const m of ['PUT', 'DELETE', 'PATCH'].filter((x) => x !== w.method)) {
97
+ const s = spec(`method-${m.toLowerCase()}`, `${m} (a method the route does not take)`);
98
+ const res = await send(m, wire(w, st).text);
99
+ if (await run.transportFault(res, s, '405 with Allow (or 404)')) continue;
100
+ if (ACCEPTED(res.s)) run.fail('accepted-invalid', s, res.s, '405 with Allow (or 404)');
101
+ else if (res.s === 405 && !res.headers.allow) run.fail('missing-allow', s, 405, '405 must name the allowed methods in Allow (RFC 9110 15.5.6)');
102
+ else run.pass(s, res.s, '405 with Allow (or 404)');
103
+ }
104
+ }
@@ -0,0 +1,36 @@
1
+ import { is4xx, is5xx } from '../http.mjs';
2
+
3
+ const DEFAULT_HEADER_BYTES = 20 << 10;
4
+ const HEADER_SLACK = 1024;
5
+
6
+ // Raw requests node:http refuses to send. Node, uvicorn, Go and most frameworks answer these from the parser, before any
7
+ // middleware: the app's common headers (request id, CORS, cache, security headers) are missing unless the parser error is handled.
8
+ export async function parserProbes(run, { writes }) {
9
+ const { client, opts } = run;
10
+ const host = client.base.host;
11
+ const bigHeader = (run.settings('GET', '/').maxHeaderBytes ?? DEFAULT_HEADER_BYTES - HEADER_SLACK) + HEADER_SLACK;
12
+ const smuggleTarget = writes[0]?.path ?? '/';
13
+ const RAW = [
14
+ ['garbage-request-line', 'GARBAGE request line', 'GARBAGE\r\n\r\n', '400', false],
15
+ ['non-ascii-path', 'non-ASCII bytes in the request path', Buffer.from(`GET /ñandú HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`, 'utf8'), 'any non-5xx (a 2xx is fine for a catch-all route)', true],
16
+ ['big-header', `${Math.round(bigHeader / 1024)} KB header`, `GET / HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\nX-Big: ${'a'.repeat(bigHeader)}\r\n\r\n`, '431', false],
17
+ ['cl-te-conflict', 'conflicting Content-Length and Transfer-Encoding', `POST ${smuggleTarget} HTTP/1.1\r\nHost: ${host}\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n`, '400 (request smuggling guard)', false],
18
+ ];
19
+ for (const [probe, what, payload, want, anyOk] of RAW) {
20
+ const spec = { group: 'parser', probe, target: 'raw', name: `raw: ${what}` };
21
+ const v = await client.raw(payload);
22
+ const expected = `${want} with the common headers, then the connection closes`;
23
+ if (!(await client.alive())) { run.fail('process-died', spec, v.s ?? v.kind, expected, 'the app stopped answering'); continue; }
24
+ if (v.kind === 'closed') { run.pass(spec, 'closed', expected); continue; }
25
+ if (v.kind !== 'status') { run.fail('parser-accepted', spec, v.kind, expected, v.text ?? 'no answer and no close in 3 s'); continue; }
26
+ if (is5xx(v.s)) { run.fail('server-error', spec, v.s, expected); continue; }
27
+ if (!is4xx(v.s)) {
28
+ if (anyOk) run.pass(spec, v.s, expected);
29
+ else run.fail('parser-accepted', spec, v.s, expected, probe === 'big-header' ? `the header limit is above ${Math.round(bigHeader / 1024)} KB: set maxHeaderBytes to the app's limit` : undefined);
30
+ continue;
31
+ }
32
+ const missing = opts.common.filter((h) => v.headers[h] === undefined);
33
+ if (missing.length && !opts.rawExempt) run.fail('parser-no-common-headers', spec, v.s, expected, `the parser's answer lacks ${missing.join(', ')}: the error is answered before the app's middleware`);
34
+ else run.pass(spec, v.s, expected, missing.length ? `common headers exempt: ${opts.rawExempt}` : undefined);
35
+ }
36
+ }
@@ -0,0 +1,83 @@
1
+ import { is2xx } from '../http.mjs';
2
+
3
+ const HUGE = ['9'.repeat(30), '-' + '9'.repeat(30), '1e3', ''];
4
+ // Broken percent-encoding in the path is probed separately (`/%`), so it is not repeated here.
5
+ const PATH_PARAMS = [['huge-number', '9'.repeat(30)], ['negative', '-1'], ['letters', 'abc'], ['invalid-uuid', 'not-a-uuid']];
6
+
7
+ // Marker, regexp pairs: a hit on a 2xx that is not an HTML page means the file came back.
8
+ const LEAKS = [
9
+ ['/.git/config', /\[core\]/], ['/.env', /^[A-Z][A-Z0-9_]*=/m], ['/package.json', /"(dependencies|scripts)"\s*:/],
10
+ ['/../../etc/passwd', /root:.*:0:0:/], ['/%2e%2e/%2e%2e/etc/passwd', /root:.*:0:0:/],
11
+ ['/..%5c..%5cwindows/win.ini', /\[fonts\]|for 16-bit app support/i],
12
+ ];
13
+
14
+ // Digits and long hex/uuid runs vary between two responses of the same route; compare without them.
15
+ const shape = (text) => text.replace(/[0-9a-f-]{8,}|\d+/gi, '#');
16
+
17
+ export async function pathProbes(run, { writes, itemTemplate }) {
18
+ const { client, opts } = run;
19
+ const routes = [...new Set(['/', ...writes.map((w) => w.path), ...opts.reads])];
20
+ const spec = (probe, target, name) => ({ group: 'paths', probe, target, name });
21
+ const noFiveHundred = async (probe, target, name, path, expected) => {
22
+ const s = spec(probe, target, name);
23
+ const res = await client.send('GET', path);
24
+ if (!(await run.transportFault(res, s, expected))) run.pass(s, res.s, expected);
25
+ };
26
+
27
+ const seenPaths = new Set(); // path-level probes run once per path; the query only matters for the query-level ones
28
+ for (const r of routes) {
29
+ const [p, q] = r.split('?');
30
+ if (!seenPaths.has(p)) {
31
+ seenPaths.add(p);
32
+ const pathTarget = `GET ${p}`;
33
+ const s = spec('double-slash', pathTarget, `GET /${p} (double slash)`);
34
+ const [slash, plain] = [await client.send('GET', '/' + p), await client.send('GET', p)];
35
+ if (!(await run.transportFault(slash, s, 'a 4xx or the same resource, never a 5xx: "//x" is a path, not a host'))) {
36
+ if (is2xx(slash.s) && (slash.s !== plain.s || shape(slash.text) !== shape(plain.text))) {
37
+ run.fail('double-slash-mismatch', s, slash.s, `the same answer as ${p} (${plain.s}) or a 4xx`, `//${p.slice(1)} answered something else than ${p}: it is likely parsed as a host`);
38
+ } else run.pass(s, slash.s, 'a 4xx or the same resource');
39
+ }
40
+ const base = p.replace(/\/$/, '');
41
+ await noFiveHundred('percent-path', pathTarget, `GET ${base}/% (broken percent-encoding in the path)`, `${base}/%`, '400 or 404, never a 5xx');
42
+ }
43
+ const target = `GET ${r}`;
44
+ await noFiveHundred('percent-query', target, `GET ${p}?pct=%FF% (broken percent-encoding in the query)`, `${p}?${q ? `${q}&` : ''}pct=%FF%`, '400, never a 5xx');
45
+ for (const [k] of [...new URLSearchParams(q ?? '')].filter(([, v]) => /^-?\d+$/.test(v))) {
46
+ for (const huge of HUGE) {
47
+ const sp = new URLSearchParams(q); sp.set(k, huge);
48
+ await noFiveHundred(`number-${k}-${huge.slice(0, 6) || 'empty'}`, target, `GET ${p}?${k}=${huge.slice(0, 6) || '(empty)'} (number the parser cannot hold)`, `${p}?${sp}`, '400 or a clamped 2xx, never a 5xx');
49
+ }
50
+ }
51
+ }
52
+
53
+ if (itemTemplate) {
54
+ for (const [tag, value] of PATH_PARAMS) {
55
+ await noFiveHundred(`path-param-${tag}`, `GET ${itemTemplate}`, `GET ${itemTemplate.replace('{id}', value)} (path parameter)`, itemTemplate.replace('{id}', value), '400 or 404, never a 5xx');
56
+ }
57
+ } else {
58
+ run.notRun(spec('path-params', 'GET {item}', 'path parameter probes (huge number, letters, invalid UUID)'),
59
+ 'no item route is known: pass --item-path "/items/{id}" or have the create answer a Location');
60
+ }
61
+
62
+ const readable = [...new Set(opts.reads.map((x) => x.split('?')[0]))];
63
+ const all = [...new Set([...writes.map((w) => w.path), ...readable])];
64
+ for (const r of all) for (const m of ['HEAD', 'OPTIONS']) {
65
+ if (m === 'HEAD' && !readable.includes(r)) continue;
66
+ const s = spec(m.toLowerCase(), `${m} ${r}`, `${m} ${r}`);
67
+ const res = await client.send(m, r);
68
+ const expected = '2xx, or 405 with Allow';
69
+ if (await run.transportFault(res, s, expected)) continue;
70
+ // Both findings can apply to one answer: HEAD refused, and without an Allow header.
71
+ if (res.s === 405 && m === 'HEAD') run.fail('head-unsupported', s, 405, 'HEAD answered like GET without the body (RFC 9110 9.1)');
72
+ if (res.s === 405 && !res.headers.allow) run.fail('missing-allow', s, 405, '405 must name the allowed methods in Allow (RFC 9110 15.5.6)');
73
+ if (res.s !== 405 || (m === 'OPTIONS' && res.headers.allow)) run.pass(s, res.s, expected);
74
+ }
75
+
76
+ for (const [p, marker] of LEAKS) {
77
+ const s = spec('file-served', `GET ${p}`, `GET ${p} (file the app must not serve)`);
78
+ const res = await client.send('GET', p, { common: false });
79
+ if (await run.transportFault(res, s, '404')) continue;
80
+ if (is2xx(res.s) && !/^\s*<(!doctype|html)/i.test(res.text) && marker.test(res.text)) run.fail('file-served', s, res.s, '404', 'the file content came back');
81
+ else run.pass(s, res.s, '404 (an SPA index page is fine)');
82
+ }
83
+ }