http-attack 0.0.0-stage → 1.0.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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/package.json +34 -4
- package/skills/http-attack/SKILL.md +191 -0
- package/skills/http-attack/references/fixes.md +111 -0
- package/skills/http-attack/scripts/http-attack.mjs +13 -0
- package/skills/http-attack/scripts/lib/args.mjs +190 -0
- package/skills/http-attack/scripts/lib/body.mjs +101 -0
- package/skills/http-attack/scripts/lib/cli.mjs +113 -0
- package/skills/http-attack/scripts/lib/config.mjs +142 -0
- package/skills/http-attack/scripts/lib/errors.mjs +2 -0
- package/skills/http-attack/scripts/lib/http.mjs +139 -0
- package/skills/http-attack/scripts/lib/probes/auth.mjs +159 -0
- package/skills/http-attack/scripts/lib/probes/fault.mjs +61 -0
- package/skills/http-attack/scripts/lib/probes/input.mjs +104 -0
- package/skills/http-attack/scripts/lib/probes/parser.mjs +36 -0
- package/skills/http-attack/scripts/lib/probes/paths.mjs +83 -0
- package/skills/http-attack/scripts/lib/probes/persist.mjs +69 -0
- package/skills/http-attack/scripts/lib/probes/race.mjs +124 -0
- package/skills/http-attack/scripts/lib/probes/resources.mjs +44 -0
- package/skills/http-attack/scripts/lib/probes/search.mjs +74 -0
- package/skills/http-attack/scripts/lib/registry.mjs +39 -0
- package/skills/http-attack/scripts/lib/report.mjs +63 -0
- package/skills/http-attack/scripts/lib/run.mjs +58 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { UsageError } from './errors.mjs';
|
|
4
|
+
|
|
5
|
+
export const HELP = `http-attack: adversarial probes against an HTTP API running on this machine.
|
|
6
|
+
|
|
7
|
+
usage: node http-attack.mjs <base-url> [options]
|
|
8
|
+
|
|
9
|
+
Routes
|
|
10
|
+
--write "<METHOD> <path> <body>" a request that creates or changes state, with a legitimate body. The body is JSON
|
|
11
|
+
(starts with { or [), a form (a=1&b=2) or @file to read it from a file (use a file in
|
|
12
|
+
Windows PowerShell 5.1, which strips the quotes of inline JSON). Repeatable.
|
|
13
|
+
--read "<path>" a request that returns data, with its query if any (?limit=10). Repeatable.
|
|
14
|
+
--search "<path> <param>" a read with a free-text search parameter. Repeatable.
|
|
15
|
+
--unique "<METHOD> <path> <body>" a create guarded by a unique key: N identical requests are sent at once. Repeatable.
|
|
16
|
+
--item-path "/items/{id}" where a created item is read back when the create answers no Location.
|
|
17
|
+
--vary "<json-pointer | field>" the body field that keeps requests apart (default: the first string with an @, else
|
|
18
|
+
the first string), e.g. /item/email for {"item":{"email":...}}.
|
|
19
|
+
--content-type <type> the media type of every write body (default application/json, or a form).
|
|
20
|
+
|
|
21
|
+
Credentials
|
|
22
|
+
--header "Name: value" sent on every request (user A). Repeatable.
|
|
23
|
+
--auth-env <VAR> sends "Authorization: Bearer $VAR" without the token on the command line.
|
|
24
|
+
--auth-header-name <name> a custom header that carries credentials (X-Auth-Token): removed in the no-credentials rows.
|
|
25
|
+
--other-header "Name: value" user B's credentials, for the ownership (IDOR) rows. Repeatable.
|
|
26
|
+
--other-auth-env <VAR> user B's bearer token from an environment variable.
|
|
27
|
+
|
|
28
|
+
Expectations
|
|
29
|
+
--common-headers "A, B" headers every response must carry, parser errors included.
|
|
30
|
+
--raw-exempt "<why>" parser errors are answered by a proxy before the app: skip the common-header check there.
|
|
31
|
+
--body-limit <bytes> the documented body limit.
|
|
32
|
+
--csrf the app authenticates with cookies: cross-origin writes must be refused.
|
|
33
|
+
--config <file> per-route expectations and suppressions (default ./http-attack.config.json if present).
|
|
34
|
+
|
|
35
|
+
Faults and durability
|
|
36
|
+
--fault-sqlite <file> holds BEGIN EXCLUSIVE on the app's SQLite file while the first --write is sent.
|
|
37
|
+
Any other fault (docker pause db) is a shell command and is only read from the config
|
|
38
|
+
file (faultCmd, faultRestore), never from the command line.
|
|
39
|
+
--persist-save <file> create one item through the first --write, save how to read it back, then stop.
|
|
40
|
+
--persist-verify <file> after a restart, read the saved item back and compare it.
|
|
41
|
+
|
|
42
|
+
Tuning
|
|
43
|
+
--race-count <n> identical requests per race (default 8).
|
|
44
|
+
--race-attempts <n> races to try before concluding (default 1).
|
|
45
|
+
--hang-ms <ms> how long a never-ending body is waited for (default 6000).
|
|
46
|
+
--format text|json report format (default text).
|
|
47
|
+
--help this text.
|
|
48
|
+
|
|
49
|
+
Exit codes: 0 no findings (after suppressions) · 1 findings · 2 the run could not complete (crash, bad argument, app
|
|
50
|
+
down, zero probes run, probes not run).`;
|
|
51
|
+
|
|
52
|
+
const need = (cond, msg) => { if (!cond) throw new UsageError(msg); };
|
|
53
|
+
|
|
54
|
+
const intFlag = (flag, text, min, max) => {
|
|
55
|
+
const n = Number(text);
|
|
56
|
+
need(text !== '' && Number.isInteger(n) && n >= min && n <= max, `${flag} wants a whole number between ${min} and ${max}, got "${text}".`);
|
|
57
|
+
return n;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Git Bash and MSYS2 rewrite "/path" into a Windows path before node sees it. The path is restored when its prefix is a
|
|
61
|
+
// known install root; a path given without the leading slash is also accepted.
|
|
62
|
+
const unixRoots = (env) => [env.EXEPATH, env.MSYSTEM_PREFIX && resolve(env.MSYSTEM_PREFIX, '..'), 'C:\\msys64', 'C:\\msys32']
|
|
63
|
+
.filter(Boolean).map((p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase());
|
|
64
|
+
export const unmangle = (value, env = process.env) => {
|
|
65
|
+
const text = String(value);
|
|
66
|
+
if (!/^[A-Za-z]:[\\/]/.test(text)) return text;
|
|
67
|
+
const slashed = text.replace(/\\/g, '/');
|
|
68
|
+
for (const root of unixRoots(env)) if (slashed.toLowerCase().startsWith(`${root}/`)) return slashed.slice(root.length);
|
|
69
|
+
return slashed.replace(/^[A-Za-z]:\/.*?\/(?:Git|msys64|msys32)\//i, '/');
|
|
70
|
+
};
|
|
71
|
+
// A request path is printable ASCII: a space or a non-ASCII character would crash the HTTP client, so it is rejected here.
|
|
72
|
+
export const toPath = (value, env) => {
|
|
73
|
+
const path = '/' + unmangle(value, env).replace(/^\/+/, '');
|
|
74
|
+
if (/[^\x21-\x7e]/.test(path)) throw new UsageError(`"${path}" is not a valid request path: percent-encode spaces and non-ASCII characters (a%20b).`);
|
|
75
|
+
return path;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
79
|
+
const headerInto = (target, line, flag) => {
|
|
80
|
+
const at = String(line).indexOf(':');
|
|
81
|
+
const key = String(line).slice(0, at).trim().toLowerCase();
|
|
82
|
+
need(at > 0 && HEADER_NAME.test(key), `${flag} wants "Name: value", got "${line}".`);
|
|
83
|
+
target[key] = String(line).slice(at + 1).trim();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const readBodyFile = (name, cwd, flag) => {
|
|
87
|
+
try { return readFileSync(resolve(cwd, name), 'utf8').replace(/^\uFEFF/, '').trim(); }
|
|
88
|
+
catch (e) { throw new UsageError(`${flag}: cannot read the body file "${name}": ${e.code ?? e.message}`); }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// A body that looks like JSON (or is sent as a JSON media type) must parse: a mangled body never goes out silently.
|
|
92
|
+
// Where a JSON parse error is, without the text around it (a file may hold secrets).
|
|
93
|
+
export const errorPosition = (e) => {
|
|
94
|
+
const m = String(e.message).match(/position (\d+)(?: \(line (\d+) column (\d+)\))?/);
|
|
95
|
+
return m ? `at position ${m[1]}${m[2] ? ` (line ${m[2]}, column ${m[3]})` : ''}` : 'at an unknown position';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// `source` names a file when the body came from one: the message then shows the position, never the content.
|
|
99
|
+
export const checkBody = (body, contentType, flag, source) => {
|
|
100
|
+
if (!body) return;
|
|
101
|
+
if (!/^[{[]/.test(body) && !/json/i.test(contentType ?? '')) return;
|
|
102
|
+
try { JSON.parse(body); } catch (e) {
|
|
103
|
+
throw new UsageError(source
|
|
104
|
+
? `${flag}: ${source} is not valid JSON, ${errorPosition(e)}.`
|
|
105
|
+
: `${flag}: the body is not valid JSON, ${errorPosition(e)}.\nreceived: ${body.slice(0, 300)}\n`
|
|
106
|
+
+ 'PowerShell 5.1 strips the quotes of inline JSON: put the body in a file and pass "<METHOD> <path> @body.json".');
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const requestSpec = (text, flag, { cwd, env, contentType }) => {
|
|
111
|
+
const [method, path, ...rest] = String(text ?? '').split(' ');
|
|
112
|
+
need(method && path, `${flag} wants "<METHOD> <path> <body>", got "${text ?? ''}".`);
|
|
113
|
+
need(/^[A-Za-z]+$/.test(method), `${flag}: "${method}" is not an HTTP method.`);
|
|
114
|
+
let body = rest.join(' ');
|
|
115
|
+
const fromFile = body.startsWith('@') ? body.slice(1) : null;
|
|
116
|
+
if (fromFile) body = readBodyFile(fromFile, cwd, flag);
|
|
117
|
+
checkBody(body, contentType, flag, fromFile && `the body file "${fromFile}"`);
|
|
118
|
+
return { method: method.toUpperCase(), path: toPath(path, env), body, contentType };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const FLAGS_WITH_VALUE = new Set(['--write', '--read', '--search', '--unique', '--item-path', '--vary', '--content-type', '--header',
|
|
122
|
+
'--auth-env', '--auth-header-name', '--other-header', '--other-auth-env', '--common-headers', '--raw-exempt', '--body-limit', '--config',
|
|
123
|
+
'--fault-sqlite', '--persist-save', '--persist-verify', '--race-count', '--race-attempts',
|
|
124
|
+
'--hang-ms', '--format']);
|
|
125
|
+
|
|
126
|
+
const LOOPBACK = /^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|[\w-]+\.localhost)$/i;
|
|
127
|
+
|
|
128
|
+
export const parseBase = (text, env = process.env) => {
|
|
129
|
+
need(/^https?:\/\//i.test(text ?? ''), 'The first argument is the base URL, e.g. http://127.0.0.1:3000.');
|
|
130
|
+
let base;
|
|
131
|
+
try { base = new URL(text); } catch { throw new UsageError(`"${text}" is not a valid URL.`); }
|
|
132
|
+
need(base.protocol === 'http:', 'Only plain http is supported: probe the app behind its TLS terminator.');
|
|
133
|
+
// Loopback is not "local": a tunnel (kubectl port-forward, ssh -L, a cloud SQL proxy) passes this check.
|
|
134
|
+
need(LOOPBACK.test(base.hostname) || env.HTTP_ATTACK_ALLOW_REMOTE === '1',
|
|
135
|
+
`${base.hostname} is not loopback. These probes create and delete data: run them only against an app you own.`);
|
|
136
|
+
return base;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function parseArgs(argv, { cwd = process.cwd(), env = process.env } = {}) {
|
|
140
|
+
const opt = {
|
|
141
|
+
writes: [], reads: [], searches: [], uniques: [], common: [], headers: {}, otherHeaders: {}, authHeaderNames: [],
|
|
142
|
+
csrf: false, format: 'text', help: false, raceCount: 8, raceAttempts: 1, hangMs: 6000,
|
|
143
|
+
};
|
|
144
|
+
if (argv.includes('--help') || argv.includes('-h')) return { help: true, opt: { ...opt, help: true } };
|
|
145
|
+
const base = parseBase(argv[0], env);
|
|
146
|
+
// Bodies are parsed after the loop, so --content-type may come after --write.
|
|
147
|
+
const pendingRequests = [];
|
|
148
|
+
for (let i = 1; i < argv.length; i++) {
|
|
149
|
+
const flag = argv[i];
|
|
150
|
+
if (flag === '--csrf') { opt.csrf = true; continue; }
|
|
151
|
+
need(FLAGS_WITH_VALUE.has(flag), `Unknown option ${flag}. See --help.`);
|
|
152
|
+
need(i + 1 < argv.length, `${flag} needs a value.`);
|
|
153
|
+
const value = argv[++i];
|
|
154
|
+
switch (flag) {
|
|
155
|
+
case '--write': pendingRequests.push(['writes', flag, value]); break;
|
|
156
|
+
case '--unique': pendingRequests.push(['uniques', flag, value]); break;
|
|
157
|
+
case '--read': opt.reads.push(toPath(value, env)); break;
|
|
158
|
+
case '--search': {
|
|
159
|
+
const [path, param] = unmangle(value, env).split(' ');
|
|
160
|
+
need(path && param, '--search wants "<path> <param>".');
|
|
161
|
+
opt.searches.push({ path: toPath(path, env), param });
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case '--header': headerInto(opt.headers, value, flag); break;
|
|
165
|
+
case '--other-header': headerInto(opt.otherHeaders, value, flag); break;
|
|
166
|
+
case '--auth-env': need(env[value], `--auth-env ${value}: the variable is empty or unset.`); opt.headers.authorization = `Bearer ${env[value]}`; break;
|
|
167
|
+
case '--other-auth-env': need(env[value], `--other-auth-env ${value}: the variable is empty or unset.`); opt.otherHeaders.authorization = `Bearer ${env[value]}`; break;
|
|
168
|
+
case '--auth-header-name': need(HEADER_NAME.test(value.toLowerCase()), `--auth-header-name: "${value}" is not a header name.`); opt.authHeaderNames.push(value.toLowerCase()); break;
|
|
169
|
+
case '--item-path': opt.itemPath = toPath(value, env); need(opt.itemPath.includes('{id}'), '--item-path must contain {id}.'); break;
|
|
170
|
+
case '--vary': opt.vary = value; break;
|
|
171
|
+
case '--content-type': need(/^[\w.+-]+\/[\w.+-]+/.test(value), `--content-type: "${value}" is not a media type.`); opt.contentType = value; break;
|
|
172
|
+
case '--common-headers': opt.common = value.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean); break;
|
|
173
|
+
case '--raw-exempt': opt.rawExempt = value; break;
|
|
174
|
+
case '--body-limit': opt.bodyLimit = intFlag(flag, value, 16, 2 ** 31); break;
|
|
175
|
+
case '--config': opt.config = value; break;
|
|
176
|
+
case '--fault-sqlite': opt.faultSqlite = value; break;
|
|
177
|
+
case '--persist-save': opt.persistSave = value; break;
|
|
178
|
+
case '--persist-verify': opt.persistVerify = value; break;
|
|
179
|
+
case '--race-count': opt.raceCount = intFlag(flag, value, 2, 64); break;
|
|
180
|
+
case '--race-attempts': opt.raceAttempts = intFlag(flag, value, 1, 20); break;
|
|
181
|
+
case '--hang-ms': opt.hangMs = intFlag(flag, value, 100, 600_000); break;
|
|
182
|
+
case '--format': need(value === 'text' || value === 'json', `--format wants text or json, got "${value}".`); opt.format = value; break;
|
|
183
|
+
default: throw new UsageError(`Unknown option ${flag}.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
need(!Object.keys(opt.otherHeaders).length || !Object.entries(opt.otherHeaders).every(([k, v]) => opt.headers[k] === v),
|
|
187
|
+
'The credentials for user B are the same credentials as user A: the ownership rows would prove nothing.');
|
|
188
|
+
for (const [list, flag, value] of pendingRequests) opt[list].push(requestSpec(value, flag, { cwd, env, contentType: opt.contentType }));
|
|
189
|
+
return { help: false, base, opt };
|
|
190
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Request bodies: parsing, the per-run variation that keeps requests apart, and edits to one string field.
|
|
2
|
+
|
|
3
|
+
export const FORM = 'application/x-www-form-urlencoded';
|
|
4
|
+
export const JSON_TYPE = 'application/json';
|
|
5
|
+
|
|
6
|
+
const RUN = Date.now().toString(36).slice(-5);
|
|
7
|
+
let counter = 0;
|
|
8
|
+
export const nextCounter = () => ++counter;
|
|
9
|
+
|
|
10
|
+
// A value a format rule would reject if suffixed: no letters (a number, a date) or a UUID.
|
|
11
|
+
const FORMATTED = /^[^\p{L}]*$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
12
|
+
const isFree = (v) => typeof v === 'string' && !FORMATTED.test(v);
|
|
13
|
+
|
|
14
|
+
export const isJsonBody = (w) => /^\s*[{[]/.test(w.body) || /json/i.test(w.contentType ?? '');
|
|
15
|
+
|
|
16
|
+
const unescapePointer = (s) => s.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
17
|
+
// "/item/email" or "item.email" or "email" => ['item','email']
|
|
18
|
+
export const pointerPath = (p) => (p.startsWith('/') ? p.slice(1).split('/').map(unescapePointer) : p.split('.'));
|
|
19
|
+
|
|
20
|
+
const getAt = (root, path) => path.reduce((o, k) => (o !== null && typeof o === 'object' ? o[k] : undefined), root);
|
|
21
|
+
const setAt = (root, path, value) => {
|
|
22
|
+
const parent = getAt(root, path.slice(0, -1));
|
|
23
|
+
parent[path.at(-1)] = value;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Every string leaf of a JSON value, as a path.
|
|
27
|
+
const stringLeaves = (value, path = [], out = []) => {
|
|
28
|
+
if (typeof value === 'string') out.push(path);
|
|
29
|
+
else if (value && typeof value === 'object') for (const [k, v] of Object.entries(value)) stringLeaves(v, [...path, Array.isArray(value) ? Number(k) : k], out);
|
|
30
|
+
return out;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The field that keeps requests apart: the explicit one, else the first free string containing @, else the first free string.
|
|
34
|
+
const chooseVaryPath = (value, vary) => {
|
|
35
|
+
if (vary) {
|
|
36
|
+
const path = pointerPath(vary);
|
|
37
|
+
return typeof getAt(value, path) === 'string' ? path : null;
|
|
38
|
+
}
|
|
39
|
+
const leaves = stringLeaves(value).filter((p) => isFree(getAt(value, p)));
|
|
40
|
+
return leaves.find((p) => getAt(value, p).includes('@')) ?? leaves[0] ?? null;
|
|
41
|
+
};
|
|
42
|
+
const tagged = (v, tag) => (v.includes('@') ? v.replace('@', `${tag}@`) : `${v}${tag}`);
|
|
43
|
+
|
|
44
|
+
// The legitimate body of a write with its varying field suffixed (`-<run><n>`, before the @ of an email), so a uniqueness
|
|
45
|
+
// rule never answers in place of the defense under test, in this run or the next.
|
|
46
|
+
// Returns { text, type, path, varied, error }; `path` is the string field the probes edit.
|
|
47
|
+
export function wire(w, settings = {}, n = nextCounter()) {
|
|
48
|
+
const tag = `-${RUN}${n}`;
|
|
49
|
+
const type = w.contentType ?? settings.contentType ?? (isJsonBody(w) ? JSON_TYPE : FORM);
|
|
50
|
+
if (!isJsonBody(w)) {
|
|
51
|
+
const entries = [...new URLSearchParams(w.body)];
|
|
52
|
+
const key = settings.vary ? entries.find(([k]) => k === settings.vary)?.[0] : (entries.find(([, v]) => isFree(v) && v.includes('@')) ?? entries.find(([, v]) => isFree(v)))?.[0];
|
|
53
|
+
const out = entries.map(([k, v]) => [k, k === key ? tagged(v, tag) : v]);
|
|
54
|
+
return { text: new URLSearchParams(out).toString(), type, form: true, path: key ? [key] : null, varied: key !== undefined };
|
|
55
|
+
}
|
|
56
|
+
let value;
|
|
57
|
+
try { value = JSON.parse(w.body); } catch { return { text: w.body, type, path: null, varied: false, error: 'the body is not valid JSON' }; }
|
|
58
|
+
const path = chooseVaryPath(value, settings.vary);
|
|
59
|
+
if (path) setAt(value, path, tagged(getAt(value, path), tag));
|
|
60
|
+
const fieldPath = path ?? stringLeaves(value)[0] ?? null;
|
|
61
|
+
return { text: JSON.stringify(value), type, path: fieldPath, varied: path !== null, value,
|
|
62
|
+
error: settings.vary && !path ? `--vary "${settings.vary}" does not point at a string in the body` : undefined };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The same wire body with one string field replaced by `make(current)`. Returns null when the body has no string field.
|
|
66
|
+
export function withField(p, make) {
|
|
67
|
+
if (!p.path) return null;
|
|
68
|
+
if (p.form) {
|
|
69
|
+
const entries = [...new URLSearchParams(p.text)];
|
|
70
|
+
return new URLSearchParams(entries.map(([k, v]) => [k, k === p.path[0] ? make(v) : v])).toString();
|
|
71
|
+
}
|
|
72
|
+
const value = JSON.parse(p.text);
|
|
73
|
+
setAt(value, p.path, make(getAt(value, p.path)));
|
|
74
|
+
return JSON.stringify(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Every string field of the wire body as a path (JSON only): wrong-type probes try them all.
|
|
78
|
+
export const stringFields = (p) => (p.value && !p.form ? stringLeaves(p.value) : []);
|
|
79
|
+
|
|
80
|
+
// The wire body with one string field replaced by a non-string value (JSON only).
|
|
81
|
+
export function withValueAt(p, path, replacement) {
|
|
82
|
+
const value = JSON.parse(p.text);
|
|
83
|
+
setAt(value, path, replacement);
|
|
84
|
+
return JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The wire body with the field replaced by a non-string value (JSON only).
|
|
88
|
+
export function withValue(p, replacement) {
|
|
89
|
+
if (!p.path || p.form) return null;
|
|
90
|
+
const value = JSON.parse(p.text);
|
|
91
|
+
setAt(value, p.path, replacement);
|
|
92
|
+
return JSON.stringify(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Bytes of the body with a MARKER inside a string field, so raw bytes can replace it.
|
|
96
|
+
export function splitAtField(p) {
|
|
97
|
+
const text = withField(p, () => 'MARKER');
|
|
98
|
+
return text === null ? null : text.split('MARKER');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const caseVariant = (type) => `${type.split(';')[0].trim().replace(/(^|[/+.-])([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase())}; charset=utf-8`;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { HELP, parseArgs } from './args.mjs';
|
|
2
|
+
import { applyIgnores, loadConfig, unusedRoutes } from './config.mjs';
|
|
3
|
+
import { UsageError } from './errors.mjs';
|
|
4
|
+
import { createClient } from './http.mjs';
|
|
5
|
+
import { EXIT } from './registry.mjs';
|
|
6
|
+
import { exitCode, renderJson, renderText, summarize } from './report.mjs';
|
|
7
|
+
import { Run } from './run.mjs';
|
|
8
|
+
import { accessProbes, csrfProbes, inferTemplate, legitWrite, notRunDependents, readsWithoutCredentials } from './probes/auth.mjs';
|
|
9
|
+
import { faultProbe } from './probes/fault.mjs';
|
|
10
|
+
import { malformedInput } from './probes/input.mjs';
|
|
11
|
+
import { parserProbes } from './probes/parser.mjs';
|
|
12
|
+
import { pathProbes } from './probes/paths.mjs';
|
|
13
|
+
import { checkPersistPath, persistSave, persistVerify } from './probes/persist.mjs';
|
|
14
|
+
import { raceProbe } from './probes/race.mjs';
|
|
15
|
+
import { resourceProbes } from './probes/resources.mjs';
|
|
16
|
+
import { searchProbes } from './probes/search.mjs';
|
|
17
|
+
|
|
18
|
+
const MAX_MISSING_LISTED = 10;
|
|
19
|
+
|
|
20
|
+
// Command-line options and the config file, merged: the command line wins, lists are concatenated.
|
|
21
|
+
function mergeOptions(opt, config) {
|
|
22
|
+
const lower = (list) => list.map((h) => String(h).trim().toLowerCase()).filter(Boolean);
|
|
23
|
+
return {
|
|
24
|
+
...opt,
|
|
25
|
+
writes: [...config.writes, ...opt.writes],
|
|
26
|
+
uniques: [...config.uniques, ...opt.uniques],
|
|
27
|
+
reads: [...config.reads, ...opt.reads],
|
|
28
|
+
searches: [...config.searches, ...opt.searches],
|
|
29
|
+
common: opt.common.length ? opt.common : lower(config.commonHeaders ?? []),
|
|
30
|
+
rawExempt: opt.rawExempt ?? config.rawExempt,
|
|
31
|
+
faultCmd: config.faultCmd,
|
|
32
|
+
faultRestore: config.faultRestore,
|
|
33
|
+
authHeaderNames: [...new Set([...lower(config.authHeaderNames ?? []), ...opt.authHeaderNames])],
|
|
34
|
+
itemPath: opt.itemPath ?? config.itemPath,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runAll(run) {
|
|
39
|
+
const { opts, client } = run;
|
|
40
|
+
const first = await client.send('GET', '/', { common: false });
|
|
41
|
+
if (first.s === null) throw new UsageError(`The app does not answer at ${client.base.origin}: ${first.err}`);
|
|
42
|
+
run.itemPath = opts.itemPath;
|
|
43
|
+
run.itemTemplate = opts.itemPath;
|
|
44
|
+
run.state = {};
|
|
45
|
+
|
|
46
|
+
const ready = [];
|
|
47
|
+
const legits = new Map();
|
|
48
|
+
for (const w of opts.writes) {
|
|
49
|
+
const legit = await legitWrite(run, w);
|
|
50
|
+
legits.set(w, legit);
|
|
51
|
+
if (legit.ok && legit.item && !run.itemTemplate) run.itemTemplate = inferTemplate(legit.item, legit.res);
|
|
52
|
+
if (legit.ok) ready.push(w);
|
|
53
|
+
else notRunDependents(run, w, legit.res ? `answered ${legit.res.s}` : 'no usable answer');
|
|
54
|
+
}
|
|
55
|
+
// Before the probes that delete (IDOR) or store oversized rows, so the list still holds the legitimate rows.
|
|
56
|
+
const createdBodies = ready.map((w) => w.body);
|
|
57
|
+
for (const s of opts.searches) await searchProbes(run, s, createdBodies);
|
|
58
|
+
for (const w of ready) await accessProbes(run, w, legits.get(w));
|
|
59
|
+
await readsWithoutCredentials(run);
|
|
60
|
+
for (const w of ready) await malformedInput(run, w);
|
|
61
|
+
await pathProbes(run, { writes: opts.writes, itemTemplate: run.itemTemplate });
|
|
62
|
+
await parserProbes(run, { writes: opts.writes });
|
|
63
|
+
for (const w of ready) await resourceProbes(run, w);
|
|
64
|
+
for (const u of opts.uniques) await raceProbe(run, u);
|
|
65
|
+
if (opts.csrf) for (const w of ready) await csrfProbes(run, w);
|
|
66
|
+
if (opts.faultSqlite || opts.faultCmd) await faultProbe(run, ready[0]);
|
|
67
|
+
|
|
68
|
+
if (opts.common.length) {
|
|
69
|
+
const wanted = `every response carries ${opts.common.join(', ')}`;
|
|
70
|
+
const missing = client.missesByRoute(run.itemTemplate);
|
|
71
|
+
for (const [route, { headers, statuses }] of [...missing].sort(([x], [y]) => x.localeCompare(y))) {
|
|
72
|
+
const spec = { group: 'headers', probe: 'common-headers', target: route, name: `${route}: ${wanted}` };
|
|
73
|
+
run.fail('headers-missing', spec, [...statuses].join(','), wanted, `answered ${[...statuses].join(', ')} without ${[...headers].join(', ')}`);
|
|
74
|
+
}
|
|
75
|
+
if (!missing.size) run.pass({ group: 'headers', probe: 'common-headers', target: 'responses', name: wanted }, 'all', wanted);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Returns the exit code; output goes through `io` so tests can capture it.
|
|
80
|
+
export async function main(argv, io = {}) {
|
|
81
|
+
const out = io.stdout ?? ((s) => process.stdout.write(s));
|
|
82
|
+
const err = io.stderr ?? ((s) => process.stderr.write(s));
|
|
83
|
+
const env = io.env ?? process.env;
|
|
84
|
+
const cwd = io.cwd ?? process.cwd();
|
|
85
|
+
try {
|
|
86
|
+
const parsed = parseArgs(argv, { cwd, env });
|
|
87
|
+
if (parsed.help) { out(`${HELP}\n`); return EXIT.OK; }
|
|
88
|
+
const config = loadConfig(parsed.opt.config, { cwd, env });
|
|
89
|
+
const opts = mergeOptions(parsed.opt, config);
|
|
90
|
+
const client = createClient(parsed.base, opts);
|
|
91
|
+
const run = new Run({ client, opts, config });
|
|
92
|
+
|
|
93
|
+
if (opts.persistSave || opts.persistVerify) {
|
|
94
|
+
checkPersistPath(opts.persistSave ?? opts.persistVerify, config, cwd);
|
|
95
|
+
run.itemPath = opts.itemPath;
|
|
96
|
+
if (!(await client.alive())) throw new UsageError(`The app does not answer at ${parsed.base.origin}.`);
|
|
97
|
+
const result = opts.persistSave ? await persistSave(run, opts.persistSave) : await persistVerify(run, opts.persistVerify);
|
|
98
|
+
out(opts.format === 'json' ? `${JSON.stringify({ schemaVersion: 1, ok: result.ok, ...result.data }, null, 2)}\n` : `${result.message}\n`);
|
|
99
|
+
return result.ok ? EXIT.OK : EXIT.FINDINGS;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await runAll(run);
|
|
103
|
+
const unusedIgnores = applyIgnores(run.rows, config.ignore);
|
|
104
|
+
const summary = summarize(run.rows);
|
|
105
|
+
const report = { base: parsed.base.origin, rows: run.rows, summary, unusedIgnores, unusedRoutes: unusedRoutes(config) };
|
|
106
|
+
out(`${opts.format === 'json' ? renderJson(report) : renderText(report)}\n`);
|
|
107
|
+
return exitCode(summary);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if (e instanceof UsageError) err(`http-attack: ${e.message}\n`);
|
|
110
|
+
else err(`http-attack crashed: ${e?.stack ?? e}\n`);
|
|
111
|
+
return EXIT.ERROR;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { UsageError } from './errors.mjs';
|
|
4
|
+
import { RULES } from './registry.mjs';
|
|
5
|
+
import { checkBody, errorPosition, readBodyFile, toPath } from './args.mjs';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_CONFIG_FILE = 'http-attack.config.json';
|
|
8
|
+
|
|
9
|
+
// Settings that describe the app and may be given for every route at once.
|
|
10
|
+
const GENERAL_KEYS = ['bodyLimit', 'maxHeaderBytes', 'invalidUtf8', 'vary', 'contentType'];
|
|
11
|
+
// Settings that relax a check: only for one route, and only with the reason written down (they act as suppressions).
|
|
12
|
+
const RELAXING_KEYS = ['public', 'idempotentCreate'];
|
|
13
|
+
const ROUTE_KEYS = [...GENERAL_KEYS, ...RELAXING_KEYS, 'reason'];
|
|
14
|
+
const TOP_KEYS = [...GENERAL_KEYS, 'routes', 'ignore', 'commonHeaders', 'rawExempt', 'authHeaderNames', 'itemPath', 'persistIgnore',
|
|
15
|
+
'persistDir', 'faultCmd', 'faultRestore', 'writes', 'reads', 'searches', 'uniques'];
|
|
16
|
+
|
|
17
|
+
const fail = (file, msg) => { throw new UsageError(`${file}: ${msg}`); };
|
|
18
|
+
|
|
19
|
+
const checkRoute = (file, where, s, { top }) => {
|
|
20
|
+
if (s === null || typeof s !== 'object' || Array.isArray(s)) fail(file, `${where} must be an object.`);
|
|
21
|
+
const known = top ? GENERAL_KEYS : ROUTE_KEYS;
|
|
22
|
+
for (const k of Object.keys(s)) {
|
|
23
|
+
if (top && (RELAXING_KEYS.includes(k) || k === 'reason')) fail(file, `${where}: "${k}" is only allowed inside "routes", for one route, with a "reason".`);
|
|
24
|
+
if (!known.includes(k)) fail(file, `${where}: unknown setting "${k}" (known: ${known.join(', ')}).`);
|
|
25
|
+
}
|
|
26
|
+
for (const k of RELAXING_KEYS) if (k in s && typeof s[k] !== 'boolean') fail(file, `${where}.${k} must be true or false.`);
|
|
27
|
+
if (!top && RELAXING_KEYS.some((k) => s[k] === true) && !(typeof s.reason === 'string' && s.reason.trim())) {
|
|
28
|
+
fail(file, `${where} sets ${RELAXING_KEYS.filter((k) => s[k]).join('/')} and needs a non-empty "reason".`);
|
|
29
|
+
}
|
|
30
|
+
for (const k of ['bodyLimit', 'maxHeaderBytes']) if (k in s && !(Number.isInteger(s[k]) && s[k] > 0)) fail(file, `${where}.${k} must be a positive integer.`);
|
|
31
|
+
if ('invalidUtf8' in s && !['reject', 'replace'].includes(s.invalidUtf8)) fail(file, `${where}.invalidUtf8 must be "reject" or "replace".`);
|
|
32
|
+
for (const k of ['vary', 'contentType', 'reason']) if (k in s && typeof s[k] !== 'string') fail(file, `${where}.${k} must be a string.`);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const requestOf = (file, where, r, cwd, env) => {
|
|
36
|
+
if (!r || typeof r !== 'object' || typeof r.path !== 'string') fail(file, `${where} needs a "path".`);
|
|
37
|
+
let body = r.body ?? '';
|
|
38
|
+
const fromFile = typeof body === 'string' && body.startsWith('@') ? body.slice(1) : null;
|
|
39
|
+
if (typeof body === 'object') body = JSON.stringify(body);
|
|
40
|
+
else if (fromFile) body = readBodyFile(fromFile, cwd, `${file} ${where}`);
|
|
41
|
+
else if (typeof body !== 'string') fail(file, `${where}.body must be an object, a string or "@file".`);
|
|
42
|
+
checkBody(body, r.contentType, `${file} ${where}`, fromFile && `the body file "${fromFile}"`);
|
|
43
|
+
return { method: String(r.method ?? 'POST').toUpperCase(), path: toPath(r.path, env), body, contentType: r.contentType };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// An ignore id is a finding id or a prefix ending in *, always starting with a known rule: a bare "*" silences everything.
|
|
47
|
+
const checkIgnore = (file, entry, i) => {
|
|
48
|
+
if (typeof entry?.id !== 'string' || !entry.id.trim()) fail(file, `ignore[${i}] needs an "id" (a finding id, a rule name, or "<rule>:<prefix>*").`);
|
|
49
|
+
if (typeof entry.reason !== 'string' || !entry.reason.trim()) fail(file, `ignore[${i}] ("${entry.id}") needs a non-empty "reason": a suppression without a reason is not accepted.`);
|
|
50
|
+
const rule = entry.id.split(':')[0];
|
|
51
|
+
if (!(rule in RULES)) fail(file, `ignore[${i}]: "${entry.id}" does not start with a known rule (known: ${Object.keys(RULES).join(', ')}).`);
|
|
52
|
+
if (rule === 'not-run' && !/^not-run:[^*:]/.test(entry.id)) fail(file, `ignore[${i}]: "${entry.id}" would hide every probe that could not run: name the probe, as "not-run:<probe>" or "not-run:<probe>:<target>".`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function loadConfig(file, { cwd = process.cwd(), env = process.env } = {}) {
|
|
56
|
+
const explicit = file !== undefined;
|
|
57
|
+
const path = resolve(cwd, file ?? DEFAULT_CONFIG_FILE);
|
|
58
|
+
const empty = { settings: {}, routes: {}, usedRoutes: new Set(), ignore: [], persistIgnore: [], writes: [], reads: [], searches: [], uniques: [], path: null };
|
|
59
|
+
if (!explicit && !existsSync(path)) return empty;
|
|
60
|
+
let raw;
|
|
61
|
+
try { raw = readFileSync(path, 'utf8').replace(/^\uFEFF/, ''); } catch (e) { fail(path, `cannot read the config file: ${e.code ?? e.message}`); }
|
|
62
|
+
let json;
|
|
63
|
+
try { json = JSON.parse(raw); } catch (e) { fail(path, `not valid JSON, ${errorPosition(e)}.`); }
|
|
64
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) fail(path, 'the top level must be an object.');
|
|
65
|
+
for (const k of Object.keys(json)) {
|
|
66
|
+
if (TOP_KEYS.includes(k)) continue;
|
|
67
|
+
if (ROUTE_KEYS.includes(k)) fail(path, `"${k}" is only allowed inside "routes", for one route, with a "reason".`);
|
|
68
|
+
fail(path, `unknown key "${k}" (known: ${TOP_KEYS.join(', ')}).`);
|
|
69
|
+
}
|
|
70
|
+
checkRoute(path, 'top level', Object.fromEntries(Object.entries(json).filter(([k]) => GENERAL_KEYS.includes(k))), { top: true });
|
|
71
|
+
const routes = {};
|
|
72
|
+
for (const [k, v] of Object.entries(json.routes ?? {})) {
|
|
73
|
+
checkRoute(path, `routes["${k}"]`, v, { top: false });
|
|
74
|
+
const m = k.match(/^([A-Za-z]+) (.+)$/);
|
|
75
|
+
routes[m ? `${m[1].toUpperCase()} ${toPath(m[2], env)}` : toPath(k, env)] = v;
|
|
76
|
+
}
|
|
77
|
+
const ignore = json.ignore ?? [];
|
|
78
|
+
if (!Array.isArray(ignore)) fail(path, '"ignore" must be a list.');
|
|
79
|
+
ignore.forEach((entry, i) => checkIgnore(path, entry, i));
|
|
80
|
+
for (const k of ['faultCmd', 'faultRestore', 'persistDir']) if (k in json && typeof json[k] !== 'string') fail(path, `"${k}" must be a string.`);
|
|
81
|
+
if (json.faultRestore && !json.faultCmd) fail(path, '"faultRestore" needs "faultCmd".');
|
|
82
|
+
const list = (k) => { const v = json[k] ?? []; if (!Array.isArray(v)) fail(path, `"${k}" must be a list.`); return v; };
|
|
83
|
+
const requests = (k) => list(k).map((r, i) => requestOf(path, `${k}[${i}]`, r, cwd, env));
|
|
84
|
+
return {
|
|
85
|
+
path,
|
|
86
|
+
settings: Object.fromEntries(Object.entries(json).filter(([k]) => GENERAL_KEYS.includes(k))),
|
|
87
|
+
routes,
|
|
88
|
+
usedRoutes: new Set(),
|
|
89
|
+
ignore,
|
|
90
|
+
persistIgnore: list('persistIgnore'),
|
|
91
|
+
persistDir: json.persistDir ? resolve(cwd, json.persistDir) : undefined,
|
|
92
|
+
faultCmd: json.faultCmd, faultRestore: json.faultRestore,
|
|
93
|
+
commonHeaders: json.commonHeaders, rawExempt: json.rawExempt, authHeaderNames: json.authHeaderNames, itemPath: json.itemPath,
|
|
94
|
+
writes: requests('writes'), uniques: requests('uniques'),
|
|
95
|
+
reads: list('reads').map((r) => toPath(r, env)),
|
|
96
|
+
searches: list('searches').map((s, i) => {
|
|
97
|
+
if (typeof s?.path !== 'string' || typeof s.param !== 'string') fail(path, `searches[${i}] needs "path" and "param".`);
|
|
98
|
+
return { path: toPath(s.path, env), param: s.param };
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A route key matches a path literally, or as a template: "/items/{id}" matches "/items/42".
|
|
104
|
+
const keyMatches = (keyPath, path) => keyPath === path
|
|
105
|
+
|| (keyPath.includes('{') && new RegExp(`^${keyPath.split(/\{[^}]*\}/).map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[^/]+')}$`).test(path));
|
|
106
|
+
|
|
107
|
+
// Settings for one route: top-level defaults, then the matching path keys, then "METHOD path"; the command line wins over the defaults.
|
|
108
|
+
export const routeSettings = (config, flags, method, path) => {
|
|
109
|
+
const bare = path.split('?')[0];
|
|
110
|
+
const merged = {
|
|
111
|
+
...config.settings,
|
|
112
|
+
...(flags.bodyLimit ? { bodyLimit: flags.bodyLimit } : {}),
|
|
113
|
+
...(flags.vary ? { vary: flags.vary } : {}),
|
|
114
|
+
};
|
|
115
|
+
const take = (key) => { config.usedRoutes?.add(key); Object.assign(merged, config.routes[key]); };
|
|
116
|
+
const keys = Object.keys(config.routes);
|
|
117
|
+
for (const key of keys) if (!/^[A-Z]+ /.test(key) && keyMatches(key, bare)) take(key);
|
|
118
|
+
for (const key of keys) {
|
|
119
|
+
const m = key.match(/^([A-Z]+) (.+)$/);
|
|
120
|
+
if (m && m[1] === method.toUpperCase() && keyMatches(m[2], bare)) take(key);
|
|
121
|
+
}
|
|
122
|
+
return merged;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const unusedRoutes = (config) => Object.keys(config.routes).filter((k) => !config.usedRoutes.has(k));
|
|
126
|
+
|
|
127
|
+
// A pattern is an id, a rule, "<rule>:<probe>" (every target of that probe) or an id prefix ending in *.
|
|
128
|
+
const matches = (pattern, row) => pattern === row.id || pattern === row.rule || row.id.startsWith(`${pattern}:`)
|
|
129
|
+
|| (pattern.endsWith('*') && row.id.startsWith(pattern.slice(0, -1)));
|
|
130
|
+
|
|
131
|
+
// A suppression keeps the row, marked with its reason; it no longer counts as a finding or a gap.
|
|
132
|
+
export function applyIgnores(rows, ignore) {
|
|
133
|
+
const used = new Set();
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
if (row.verdict !== 'fail' && row.verdict !== 'not-run') continue;
|
|
136
|
+
const hit = ignore.find((entry) => matches(entry.id, row));
|
|
137
|
+
if (!hit) continue;
|
|
138
|
+
used.add(hit);
|
|
139
|
+
Object.assign(row, { was: row.verdict, verdict: 'suppressed', suppressedBy: hit.reason });
|
|
140
|
+
}
|
|
141
|
+
return ignore.filter((entry) => !used.has(entry));
|
|
142
|
+
}
|