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.
- 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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
Adversarial probes against an HTTP API running on the local machine, as a Claude Code plugin and a standalone CLI.
|
|
6
|
+
|
|
7
|
+
- Probe groups: malformed input, paths and query, raw parser errors, search (LIKE wildcards, injection), concurrent
|
|
8
|
+
duplicate creates, oversized and never-ending bodies, database faults, authentication, ownership (IDOR), CSRF, durability.
|
|
9
|
+
- Stable finding ids (`<rule>:<probe>:<target>`), a rule registry with severities, and `--format json` with `schemaVersion: 1`
|
|
10
|
+
and a summary of probes run, not run, skipped and suppressed.
|
|
11
|
+
- `http-attack.config.json`: per-route expectations (`public`, `bodyLimit`, `maxHeaderBytes`, `invalidUtf8`,
|
|
12
|
+
`idempotentCreate`, `vary`, `contentType`) and suppressions with a mandatory reason.
|
|
13
|
+
- Exit codes: 0 no findings, 1 findings, 2 the run could not complete (crash, invalid argument, app down, no probe run, probe not run).
|
|
14
|
+
- Fault injection with `--fault-sqlite`, or with any shell command set as `faultCmd`/`faultRestore` in the config file (never on the command line).
|
|
15
|
+
- Bodies from files (`@body.json`) for Windows PowerShell 5.1, nested bodies (`--vary` JSON pointer), custom media types and
|
|
16
|
+
custom authentication headers.
|
|
17
|
+
- Loopback hosts only. Zero runtime dependencies; Node 22.13 or newer.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nahuel Mosse
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# http-attack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Adversarial probes against an HTTP API running on your machine, as an npm package with a CLI, and as a Claude Code skill that runs the same CLI.
|
|
4
|
+
Unit tests prove the handler does what you thought of; http-attack sends what you did not think of, against the real
|
|
5
|
+
server: malformed bodies, hostile paths, raw parser errors, concurrent duplicate creates, bodies that never end, LIKE and
|
|
6
|
+
SQL injection in search, database faults, missing authentication, IDOR and CSRF. Each failure is reported with a severity,
|
|
7
|
+
the response that would have been correct, and a stable id you can suppress with a reason.
|
|
8
|
+
|
|
9
|
+
Zero runtime dependencies. Node 22.13 or newer. Loopback hosts only.
|
|
10
|
+
|
|
11
|
+
> The probes create and delete rows, lock the database and send 50 MB bodies. Run them against a throwaway database, and
|
|
12
|
+
> remember that a tunnel (`kubectl port-forward`, `ssh -L`, a cloud SQL proxy) answers on `127.0.0.1` too.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -D http-attack
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx http-attack http://127.0.0.1:3000 \
|
|
24
|
+
--header "Authorization: Bearer $TOKEN_A" --other-header "Authorization: Bearer $TOKEN_B" \
|
|
25
|
+
--write 'POST /items {"name":"Ana","email":"ana@example.com"}' \
|
|
26
|
+
--read "/items?limit=10" --search "/items q" \
|
|
27
|
+
--unique 'POST /items {"name":"Uno","email":"uno@example.com"}' \
|
|
28
|
+
--item-path "/items/{id}" --common-headers "X-Request-Id, Cache-Control" --body-limit 65536
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`--help` lists every option. In Windows PowerShell 5.1 put JSON bodies in a file (`--write "POST /items @body.json"`),
|
|
32
|
+
because PowerShell strips the quotes of inline JSON; a body that does not parse exits 2 and shows what arrived.
|
|
33
|
+
|
|
34
|
+
Exit codes: `0` no findings (after suppressions), `1` findings, `2` the run could not complete (crash, invalid argument,
|
|
35
|
+
app down, no probe run, or a probe that could not run).
|
|
36
|
+
|
|
37
|
+
`--format json` prints `{ schemaVersion: 1, summary, findings, rows }`. A finding id is `<rule>:<probe>:<target>`, for
|
|
38
|
+
example `server-error:path-param-letters:GET /items/{id}`; it never contains generated values.
|
|
39
|
+
|
|
40
|
+
## Config reference
|
|
41
|
+
|
|
42
|
+
`http-attack.config.json` in the project (or `--config <path>`):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"bodyLimit": 65536,
|
|
47
|
+
"commonHeaders": ["x-request-id", "cache-control"],
|
|
48
|
+
"authHeaderNames": ["x-auth-token"],
|
|
49
|
+
"itemPath": "/items/{id}",
|
|
50
|
+
"routes": {
|
|
51
|
+
"/catalog": { "public": true, "reason": "public read-only catalogue" },
|
|
52
|
+
"POST /imports": { "bodyLimit": 10485760, "invalidUtf8": "replace" },
|
|
53
|
+
"POST /things": { "idempotentCreate": true, "reason": "ON CONFLICT DO NOTHING answers 200 with the same item" }
|
|
54
|
+
},
|
|
55
|
+
"ignore": [{ "id": "parser-accepted:big-header:raw", "reason": "the load balancer caps headers at 1 MB" }]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Key | Meaning |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `public` | for one route: reads and writes without credentials are expected to succeed. Needs a `reason` |
|
|
62
|
+
| `bodyLimit` | the documented body limit in bytes |
|
|
63
|
+
| `maxHeaderBytes` | the app's header limit; the oversized-header probe sends limit + 1 KB |
|
|
64
|
+
| `invalidUtf8` | `reject` (default) or `replace` (the parser substitutes U+FFFD by design) |
|
|
65
|
+
| `idempotentCreate` | for one route: several 2xx for one key are expected. Needs a `reason` |
|
|
66
|
+
| `vary`, `contentType` | the body field that keeps requests apart (JSON pointer), the media type of write bodies |
|
|
67
|
+
| `routes` | the settings above per path (`"/items"`), per request (`"POST /items"`) or per template (`"/items/{id}"`); `public` and `idempotentCreate` exist only here. `bodyLimit`, `maxHeaderBytes`, `invalidUtf8`, `vary`, `contentType` may also be top-level defaults |
|
|
68
|
+
| `commonHeaders`, `rawExempt`, `authHeaderNames`, `itemPath`, `persistIgnore` | as the flags of the same name |
|
|
69
|
+
| `faultCmd`, `faultRestore` | shell commands that break and heal the database (`docker pause db`). Config only, never a flag, because they run a shell |
|
|
70
|
+
| `persistDir` | a directory outside the working and temp directories where `--persist-save` may write |
|
|
71
|
+
| `writes`, `reads`, `searches`, `uniques` | the routes to attack; bodies as objects or `"@file"` |
|
|
72
|
+
| `ignore` | `{ id, reason }`: a finding id, a rule name, or `<rule>:<prefix>*`; it must start with a known rule. The reason is mandatory |
|
|
73
|
+
|
|
74
|
+
## CI
|
|
75
|
+
|
|
76
|
+
Depend on the package and run it in the pipeline, with the app started against a scratch database:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
- run: npm ci
|
|
80
|
+
- run: node app.mjs & # start the app on a scratch database
|
|
81
|
+
- run: npx http-attack http://127.0.0.1:3000 --config http-attack.config.json --format json > attack.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The step fails on exit 1 (findings) and exit 2 (could not run). The lockfile pins the version, so CI and the Claude Code
|
|
85
|
+
skill (which prefers the project's own copy) run the same probes.
|
|
86
|
+
|
|
87
|
+
## Claude Code skill
|
|
88
|
+
|
|
89
|
+
The repository is also a Claude Code plugin whose skill drives the CLI: it reads the router, builds the command, triages the rows and fixes the findings.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
claude plugin marketplace add NahuelMosse/http-attack
|
|
93
|
+
claude plugin install http-attack@http-attack
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Pin a release by adding the tag to the marketplace source:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
claude plugin marketplace add NahuelMosse/http-attack#v1.0.0
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Update with `claude plugin marketplace update http-attack`, then `claude plugin update http-attack@http-attack`.
|
|
103
|
+
Then ask Claude to break-test your local API ("my Express API is running on localhost:3000, try to break it before I ship").
|
|
104
|
+
When the project has `http-attack` in its `devDependencies`, the skill runs that copy; otherwise the one bundled in the skill (`skills/http-attack/scripts/`).
|
|
105
|
+
|
|
106
|
+
The skill directory is self-contained: `npx skills add NahuelMosse/http-attack` copies only `skills/http-attack/`, and it carries its own CLI, so a vendored copy runs without the npm package.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm test # unit and integration tests
|
|
112
|
+
node tests/e2e-fixtures.mjs 3 # attack both fixtures three times: identical results, the fixed one clean
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`tests/fixtures/` holds a deliberately broken API and its fixed twin; the vulnerable one must keep reporting
|
|
116
|
+
the ids pinned in `tests/expected-vulnerable.json`.
|
|
117
|
+
|
|
118
|
+
MIT licensed. See `NOTICES.md` for the ideas this builds on.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "http-attack",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1-rc.0",
|
|
4
|
+
"description": "Adversarial probes against an HTTP API running on this machine: malformed input, parser errors, races, injection, database faults, missing auth, IDOR and CSRF.",
|
|
5
|
+
"keywords": ["http", "api", "security", "testing", "fuzzing", "idor", "csrf", "sql-injection", "hardening", "cli"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Nahuel Mosse",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/NahuelMosse/http-attack.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/NahuelMosse/http-attack#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/NahuelMosse/http-attack/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"bin": {
|
|
18
|
+
"http-attack": "skills/http-attack/scripts/http-attack.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"skills/http-attack",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.13"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"provenance": true
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test --test-concurrency=4 \"tests/*.test.mjs\""
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: http-attack
|
|
3
|
+
description: Break-test an HTTP API the user runs locally before shipping it. Sends malformed bodies, hostile paths, raw parser errors, concurrent duplicate creates, never-ending bodies, LIKE/SQL injection in search, database faults, missing auth, IDOR and CSRF, and reports each failure with its severity and the correct response. Use when asked to attack, harden, fuzz or break-test a local API, or to check it never answers 500. Not for code review, load tests, OpenAPI conformance, or hosts the user does not own.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# http-attack
|
|
7
|
+
|
|
8
|
+
Unit tests prove the handler does what the developer thought of. This skill sends what the developer did not think of,
|
|
9
|
+
against the real server, and reports each failure with its severity and the response that would have been correct.
|
|
10
|
+
|
|
11
|
+
## Scope and safety
|
|
12
|
+
|
|
13
|
+
- Only an app the user owns, running on this machine (`localhost`, `127.0.0.1`, `::1`). The script refuses other hosts.
|
|
14
|
+
- **Loopback is not proof the app is local.** `kubectl port-forward`, `ssh -L`, a cloud SQL proxy or a dev tunnel all answer on
|
|
15
|
+
`127.0.0.1` while the data lives elsewhere. Confirm with the user that the app behind the port is a local process.
|
|
16
|
+
- The probes create about 20-40 rows per write route, delete rows, lock the database and send 50 MB bodies. **Use a
|
|
17
|
+
throwaway database** (a scratch SQLite file, or a Postgres/MySQL database created for this run), never one with data
|
|
18
|
+
that matters. If you cannot confirm it is throwaway, stop and ask.
|
|
19
|
+
|
|
20
|
+
## Method
|
|
21
|
+
|
|
22
|
+
1. **Discover the surface.** Read the router (or the OpenAPI file) and list every route as one of: *create* (POST that
|
|
23
|
+
stores), *update/delete*, *read one* (`/items/{id}`), *list*, *search* (list with a free-text parameter). Note per route: the
|
|
24
|
+
auth scheme (bearer, cookie, API key, custom header), the unique keys of the stored entity, the body limit, the
|
|
25
|
+
database, the maximum header size, whether the route is public by design, and the headers every response must carry
|
|
26
|
+
(request id, `Cache-Control`, security headers).
|
|
27
|
+
2. **Start the app** on a free port with the scratch database and get credentials for two users (see below).
|
|
28
|
+
3. **Run the script** (below) with one `--write` per create/update route, `--read` per list, `--search` per search route,
|
|
29
|
+
`--unique` per create guarded by a unique key.
|
|
30
|
+
4. **Triage every row.** `FAIL` is a finding; `NOT RUN` is a probe that could not reach a verdict (the legitimate body was
|
|
31
|
+
refused, no item route is known, no rows to search): fix the input and run again, it is never a pass. A finding that is
|
|
32
|
+
correct behaviour for this app is not a bug: say so and suppress it in the config with the reason (next section), do not
|
|
33
|
+
change the app to satisfy the probe.
|
|
34
|
+
5. **Fix** real findings at the right layer (`references/fixes.md` has the fix per finding and stack), not by special-casing
|
|
35
|
+
the probe. Add a regression test per fixed finding to the app's own suite.
|
|
36
|
+
6. **Re-run the same command** until it reports **0 findings, or every remaining one is suppressed in the config with its
|
|
37
|
+
reason**, and no row is `NOT RUN` (or each is suppressed with a reason). Exit code 0 means exactly that.
|
|
38
|
+
7. **Clean up**: the run left rows in the scratch database; drop the database (or the file), do not reuse it.
|
|
39
|
+
|
|
40
|
+
## Running it
|
|
41
|
+
|
|
42
|
+
If the project lists `http-attack` in its `devDependencies`, run `npx --no-install http-attack` instead of
|
|
43
|
+
`node "${CLAUDE_SKILL_DIR}/scripts/http-attack.mjs"` in every command below: it is the version CI runs. Without that
|
|
44
|
+
dependency, use the copy bundled with this skill, as written.
|
|
45
|
+
|
|
46
|
+
Git Bash / macOS / Linux:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
MSYS_NO_PATHCONV=1 node "${CLAUDE_SKILL_DIR}/scripts/http-attack.mjs" http://127.0.0.1:3000 \
|
|
50
|
+
--header "Authorization: Bearer $TOKEN_A" --other-header "Authorization: Bearer $TOKEN_B" \
|
|
51
|
+
--write 'POST /items {"name":"Ana","email":"ana@example.com"}' \
|
|
52
|
+
--read "/items?limit=10" --search "/items q" \
|
|
53
|
+
--unique 'POST /items {"name":"Uno","email":"uno@example.com"}' \
|
|
54
|
+
--item-path "/items/{id}" --common-headers "X-Request-Id, Cache-Control" --body-limit 65536 \
|
|
55
|
+
--fault-sqlite ./data/app.db
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Windows PowerShell (5.1 strips the quotes inside inline JSON, so put bodies in files):
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
Set-Content -Encoding ascii body.json '{"name":"Ana","email":"ana@example.com"}'
|
|
62
|
+
node "${CLAUDE_SKILL_DIR}/scripts/http-attack.mjs" http://127.0.0.1:3000 `
|
|
63
|
+
--header "Authorization: Bearer $env:TOKEN_A" --write "POST /items @body.json" --read "/items?limit=10"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
An inline JSON body that does not parse exits 2 and shows what arrived. `node "${CLAUDE_SKILL_DIR}/scripts/http-attack.mjs" --help` lists every option.
|
|
67
|
+
Notes:
|
|
68
|
+
|
|
69
|
+
- Zero dependencies; Node 22.13 or newer.
|
|
70
|
+
- Every write body gets a per-run suffix on one string field (the first containing `@`, else the first string; `--vary /item/email`
|
|
71
|
+
picks another, also nested) so a uniqueness rule never answers in place of the defense under test.
|
|
72
|
+
- `--content-type application/vnd.api+json` for non-default media types; `--auth-header-name X-Auth-Token` when credentials
|
|
73
|
+
travel in a custom header; `--auth-env VAR` / `--other-auth-env VAR` keep tokens off the command line.
|
|
74
|
+
- Cookie authentication: add `--csrf` and pass the cookie with `--header "Cookie: ..."`.
|
|
75
|
+
- Persistence is a two-step run around a restart: `--persist-save state.json`, restart the app, `--persist-verify state.json` (the file must be inside the working or temp
|
|
76
|
+
directory, or the config must name its directory in `persistDir`).
|
|
77
|
+
- Other databases: `faultCmd` (`"docker pause db"`) and `faultRestore` (`"docker unpause db"`) in the config file: any shell
|
|
78
|
+
command that breaks the database and the one that heals it (the restore always runs). They are read only from the config
|
|
79
|
+
file, never from the command line, because they run a shell: have the user review them before the run.
|
|
80
|
+
- On Git Bash for Windows set `MSYS_NO_PATHCONV=1` so `/items` is not rewritten into a Windows path, and give the app and
|
|
81
|
+
`--fault-sqlite` the same absolute Windows path (`D:/app/data/app.db`).
|
|
82
|
+
|
|
83
|
+
**Two users' tokens.** Use the app's own seed or signup: create two users (alice, bob) against the scratch database, log in
|
|
84
|
+
as each, and export the tokens (`TOKEN_A`, `TOKEN_B`) or the session cookies. If the app has no login, a test helper that
|
|
85
|
+
signs a token for two different user ids works. The ownership rows need both; without user B they are skipped.
|
|
86
|
+
|
|
87
|
+
## Config file
|
|
88
|
+
|
|
89
|
+
`http-attack.config.json` in the project (or `--config <path>`) holds what the command line cannot say. Per-route settings
|
|
90
|
+
go under `routes` (key `"/items"`, `"POST /items"` or a template such as `"/items/{id}"`); `bodyLimit`, `maxHeaderBytes`,
|
|
91
|
+
`invalidUtf8`, `vary` and `contentType` may also be given at the top level for every route. `public` and `idempotentCreate` relax
|
|
92
|
+
a check, so they are accepted only inside one route and only with a `reason`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"bodyLimit": 65536,
|
|
97
|
+
"commonHeaders": ["x-request-id", "cache-control"],
|
|
98
|
+
"routes": {
|
|
99
|
+
"/catalog": { "public": true, "reason": "public read-only catalogue" },
|
|
100
|
+
"/imports": { "bodyLimit": 10485760, "invalidUtf8": "replace" },
|
|
101
|
+
"POST /things": { "idempotentCreate": true, "reason": "ON CONFLICT DO NOTHING answers 200 with the same item" }
|
|
102
|
+
},
|
|
103
|
+
"ignore": [
|
|
104
|
+
{ "id": "parser-accepted:big-header:raw", "reason": "the load balancer in front caps headers at 1 MB" }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Setting | Meaning |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `public` + `reason` | reads and writes of this route without credentials are expected to succeed (the reason is mandatory) |
|
|
112
|
+
| `bodyLimit` | the documented limit in bytes: the never-ending body sends limit+1 bytes, the multibyte probe is built from it |
|
|
113
|
+
| `maxHeaderBytes` | the app's header limit: the oversized-header probe sends limit+1 KB |
|
|
114
|
+
| `invalidUtf8` | `reject` (default) or `replace` (the parser substitutes U+FFFD by design) |
|
|
115
|
+
| `idempotentCreate` + `reason` | several 2xx for one key are expected (`ON CONFLICT DO NOTHING`); the reason is mandatory |
|
|
116
|
+
| `vary`, `contentType` | as the flags |
|
|
117
|
+
| `ignore` | `{id, reason}`: `id` is a finding id (shown in every report row), a rule name (`stack-leak`), `<rule>:<probe>` (every target of that probe), or `<rule>:<prefix>*`. It must start with a known rule and a reason is mandatory. `not-run` is never accepted alone: name the probe (`not-run:<probe>`) so one gap cannot hide the others |
|
|
118
|
+
|
|
119
|
+
The config can also hold `faultCmd`, `faultRestore`, `persistDir`, and list `writes`, `reads`, `searches`, `uniques` (bodies as objects or `"@file"`) and `persistIgnore`
|
|
120
|
+
(fields skipped when comparing the persisted item).
|
|
121
|
+
|
|
122
|
+
## Reading the report
|
|
123
|
+
|
|
124
|
+
Each row is `pass`, `FAIL <severity>`, `NOT RUN`, `suppressed` or `skipped` (the config says it does not apply). Failures show
|
|
125
|
+
`expected:`, `evidence:` and an `id:` that is stable between runs. `--format json` prints `{schemaVersion: 1, summary, findings,
|
|
126
|
+
rows}`; the summary counts `run`, `passed`, `failed`, `suppressed`, `notRun`, `skipped` and findings by severity.
|
|
127
|
+
Exit codes: `0` no findings, `1` findings, `2` the run could not complete (bad argument, app down, crash, zero probes run, or
|
|
128
|
+
some probe not run).
|
|
129
|
+
|
|
130
|
+
## Severity
|
|
131
|
+
|
|
132
|
+
Every rule the script can emit; the finding id is `<rule>:<probe>:<target>`.
|
|
133
|
+
|
|
134
|
+
| Rule | Severity | Meaning |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `process-died` | critical | the app stopped answering during a probe |
|
|
137
|
+
| `unauthenticated-access` | critical | a write or read is granted (2xx) without credentials |
|
|
138
|
+
| `idor` | critical | another user's credentials reach this user's object (2xx) |
|
|
139
|
+
| `sql-injection` | critical | an injected OR returns rows that a no-match control does not |
|
|
140
|
+
| `duplicates-stored` | critical | concurrent identical creates stored more than one distinct item |
|
|
141
|
+
| `file-served` | critical | a repo, secret or OS file came back over HTTP |
|
|
142
|
+
| `csrf` | critical | a cookie-authenticated write from a foreign Origin was accepted |
|
|
143
|
+
| `not-durable` | critical | a saved item is gone or different after a restart |
|
|
144
|
+
| `connection-dropped` | high | the connection was closed without a response while the app stayed up |
|
|
145
|
+
| `server-error` | high | a 5xx on client input (including path params, quotes and unique violations) |
|
|
146
|
+
| `accepted-invalid` | high | input that must be refused got a 2xx/3xx (bad JSON, invalid UTF-8, wrong type, other methods) |
|
|
147
|
+
| `no-body-limit` | high | a body over the limit is accepted, or a body that never ends is read forever |
|
|
148
|
+
| `double-slash-mismatch` | high | `//x` answers 2xx with something other than `/x` |
|
|
149
|
+
| `wildcard-unescaped` | high | `%` or `_` in a search reach LIKE as wildcards |
|
|
150
|
+
| `parser-accepted` | high | the HTTP parser let a malformed raw request through |
|
|
151
|
+
| `fault-not-reported` | high | a write during a database fault was not answered with a 5xx |
|
|
152
|
+
| `limit-counts-characters` | medium | the body limit counts characters, not bytes |
|
|
153
|
+
| `read-before-reject` | medium | a 50 MB body was read in full before the 415 |
|
|
154
|
+
| `media-type-strict` | medium | a case or parameter variant of the media type is refused (RFC 9110 8.3.1) |
|
|
155
|
+
| `stack-leak` | medium | a 5xx body leaks a stack trace, driver error or SQLSTATE |
|
|
156
|
+
| `parser-no-common-headers` | medium | a parser-level error lacks the app's common headers |
|
|
157
|
+
| `headers-missing` | medium | a response lacks a header every response must carry |
|
|
158
|
+
| `missing-allow` | low | 405 without an `Allow` header |
|
|
159
|
+
| `head-unsupported` | low | HEAD on a readable route answers 405 (RFC 9110 9.1) |
|
|
160
|
+
| `not-run` | - | a probe could not reach a verdict; never a pass |
|
|
161
|
+
|
|
162
|
+
A 5xx whose body leaks internals is reported twice on purpose: `server-error` (the app failed on client input) and `stack-leak`
|
|
163
|
+
(what the body disclosed) have different fixes and different owners, so each keeps its own id and can be suppressed on its own.
|
|
164
|
+
|
|
165
|
+
## What the script probes
|
|
166
|
+
|
|
167
|
+
| Group | Probes | Correct response |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| input | truncated JSON, invalid UTF-8 inside a string, no `Content-Type`, `Application/JSON; charset=utf-8`, a string field sent as number/null/array/object, 1 MB body, limit+1 bytes of multibyte characters, PUT/DELETE/PATCH on the route | 4xx, never 5xx, nothing coerced or stored |
|
|
170
|
+
| paths | `//x` vs `/x`, `/%`, `?x=%FF%`, 30-digit numbers in query and path parameters (`/items/999...`, `/items/abc`, invalid UUID), HEAD/OPTIONS, `/.git/config`, `/.env`, `../../etc/passwd`, win.ini | 4xx or the same resource, never 5xx; files 404 |
|
|
171
|
+
| search | `%`, `_`, quotes, `\`, `' OR 1=1 --`, compared as parsed rows against a search that matches nothing | only rows containing the literal text |
|
|
172
|
+
| parser (raw socket) | `GARBAGE` request line, non-ASCII bytes in the path, an oversized header, `Content-Length` with `Transfer-Encoding` | 400/431 with the common headers, connection closed |
|
|
173
|
+
| resources | 50 MB `text/plain` to a JSON route, a chunked body that never ends (sends limit+1 bytes) | 415 at the first bytes; 413 at the limit or 408/close |
|
|
174
|
+
| concurrency | N identical creates released together (last-byte sync) | exactly one 201, the rest 409 |
|
|
175
|
+
| faults | a legitimate write while the database is locked or broken (`--fault-sqlite`, or `faultCmd` in the config) | 503/500 with a generic body, the process keeps answering |
|
|
176
|
+
| auth | each write and read without credentials; user B reads and deletes A's item (after checking B can use its own) | 401 (or a redirect to login); 404/403 for B |
|
|
177
|
+
| csrf | write with a foreign `Origin`, another port of the same host, `Origin: null` | 4xx |
|
|
178
|
+
| durability | create, restart, read back | same status and body |
|
|
179
|
+
|
|
180
|
+
Also check by hand what the script cannot: mass assignment (`"role":"admin"` on create/update), excessive data exposure, function-level
|
|
181
|
+
authorization (a regular user calling admin routes), rate limits on login, business-logic abuse (double-spend of a one-time token,
|
|
182
|
+
two updates with the same version, a counter below zero). Map findings to OWASP API Security Top 10 (API1, API2, API4, API5) and ASVS
|
|
183
|
+
level 1 for the security floor of a whole application.
|
|
184
|
+
|
|
185
|
+
## What this skill does not do
|
|
186
|
+
|
|
187
|
+
- Contract conformance against an OpenAPI schema and property-based input generation: Schemathesis.
|
|
188
|
+
- Stateful sequence fuzzing over a whole API: RESTler.
|
|
189
|
+
- Known-CVE and misconfiguration scanning of the stack: OWASP ZAP API scan or Nuclei templates.
|
|
190
|
+
- Content discovery of unlinked routes: ffuf with a wordlist.
|
|
191
|
+
- Load and latency limits: a load tool.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Fixes by finding and stack
|
|
2
|
+
|
|
3
|
+
Fix the cause in the app; do not special-case the probe. If a finding is correct behaviour for the route, suppress it in
|
|
4
|
+
`http-attack.config.json` with the reason (see SKILL.md). Rule names are the ones in the severity table.
|
|
5
|
+
|
|
6
|
+
## input: `accepted-invalid`, `server-error`, `media-type-strict`, `no-body-limit`, `limit-counts-characters`
|
|
7
|
+
|
|
8
|
+
| Probe | Correct | Fix |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| Truncated JSON | 400 with the app's error body | catch the parse error in the body middleware |
|
|
11
|
+
| Invalid UTF-8 inside a string | 400, nothing stored with U+FFFD | strict decoding (below), or accept the replacement on purpose and set `invalidUtf8: "replace"` for the route |
|
|
12
|
+
| No `Content-Type` | 415, never 5xx | read the header defensively: `(h ?? '').split(';')[0].trim().toLowerCase()` |
|
|
13
|
+
| `Application/JSON; charset=utf-8` | accepted | compare the media type lowercased, without parameters (RFC 9110 8.3.1) |
|
|
14
|
+
| String field sent as number, null, array, object | 400/422, never coerced | validate types with a schema; never `String(x)` user input |
|
|
15
|
+
| 1 MB body, limit+1 bytes of multibyte characters | 413 | count **bytes** while reading, not characters after decoding |
|
|
16
|
+
| PUT/DELETE/PATCH on a route that does not take them | 405 with `Allow` | let the router answer 405 and set `Allow` |
|
|
17
|
+
|
|
18
|
+
Strict UTF-8 and body limits per stack:
|
|
19
|
+
|
|
20
|
+
- **Express**: `express.json({ limit: '64kb', verify: (req, res, buf) => { new TextDecoder('utf-8', { fatal: true }).decode(buf); } })`
|
|
21
|
+
and an error handler that maps `err.type === 'entity.too.large'` to 413 and `entity.parse.failed` or a `TypeError` from
|
|
22
|
+
`verify` to 400. Without `verify`, body-parser replaces invalid bytes with U+FFFD.
|
|
23
|
+
- **Fastify**: `Fastify({ bodyLimit: 65536 })`; add a content-type parser with `parseAs: 'buffer'` that decodes with
|
|
24
|
+
`new TextDecoder('utf-8', { fatal: true })` and calls `done(err)` on failure. The default JSON parser replaces invalid bytes.
|
|
25
|
+
- **FastAPI / Starlette**: a pydantic model rejects wrong types with 422 (`strict=True` for no coercion). Limit the body in
|
|
26
|
+
middleware that counts `await request.stream()` bytes and answers 413, or at the proxy (`--limit-max-requests` is not a body
|
|
27
|
+
limit). Invalid UTF-8 in `request.json()` raises `UnicodeDecodeError`: catch it and answer 400.
|
|
28
|
+
- **Django / DRF**: `DATA_UPLOAD_MAX_MEMORY_SIZE` (default 2.5 MB) answers 400 `RequestDataTooBig`; set it to the documented limit.
|
|
29
|
+
DRF's JSON parser rejects invalid UTF-8 with a `ParseError` (400).
|
|
30
|
+
- **Go net/http**: `r.Body = http.MaxBytesReader(w, r.Body, 64<<10)` answers `*http.MaxBytesError`: map it to 413. `encoding/json`
|
|
31
|
+
replaces invalid UTF-8 with U+FFFD: check `utf8.Valid(raw)` on the bytes read before `json.Unmarshal`. Use
|
|
32
|
+
`dec.DisallowUnknownFields()` and typed structs so a number sent for a string is an error.
|
|
33
|
+
- **Rails**: wrap params in strong parameters and check types; the JSON parser raises `ActionDispatch::Http::Parameters::ParseError`
|
|
34
|
+
(400). Limit the body at the proxy or with Rack middleware; Rails has no default limit.
|
|
35
|
+
|
|
36
|
+
## paths: `server-error` on path parameters, `double-slash-mismatch`, `file-served`
|
|
37
|
+
|
|
38
|
+
- Never feed `req.url` to `new URL(path, base)`: `//x` parses as a host. Use `req.url.split('?')` or the framework's router.
|
|
39
|
+
- Wrap `decodeURIComponent` and answer 400 on `URIError`.
|
|
40
|
+
- Validate path parameters before the database: `^[0-9]{1,15}$` for integer ids (a 30-digit id overflows `integer` in Postgres: 22003),
|
|
41
|
+
a UUID parser for UUID ids (`not-a-uuid` is Postgres 22P02). Both are client errors: 400 or 404, not 500.
|
|
42
|
+
- Serve static files from a dedicated directory, never the repository root; deny dotfiles.
|
|
43
|
+
|
|
44
|
+
## search: `wildcard-unescaped`, `sql-injection`, `server-error` on quotes
|
|
45
|
+
|
|
46
|
+
Bind the term as a parameter and escape the LIKE wildcards in it. The escape character must be declared where the dialect
|
|
47
|
+
does not default to it:
|
|
48
|
+
|
|
49
|
+
| Database | Query | Escape the term with |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| PostgreSQL | `name ILIKE $1` | `term.replace(/[\\%_]/g, '\\$&')`; `\` is the default LIKE escape, `ESCAPE '\'` is also valid |
|
|
52
|
+
| MySQL / MariaDB | `name LIKE ?` | same; `\` is already the default escape. Do **not** write `ESCAPE '\'` in a MySQL string literal: the backslash escapes the closing quote. Write `ESCAPE '\\'` (or choose another character with `ESCAPE '!'`) |
|
|
53
|
+
| SQLite | `name LIKE ? ESCAPE '\'` | same escape; SQLite has no default escape character, so `ESCAPE` is required |
|
|
54
|
+
| SQL Server | `name LIKE @p ESCAPE '\'` | escape `[` too |
|
|
55
|
+
|
|
56
|
+
A quote answered with 5xx is either string-built SQL (fix: parameters) or a driver rejecting the literal (`to_tsquery` with
|
|
57
|
+
a bare quote: use `plainto_tsquery` or `websearch_to_tsquery`). Confirm which before calling it an injection.
|
|
58
|
+
|
|
59
|
+
## parser: `parser-accepted`, `parser-no-common-headers`
|
|
60
|
+
|
|
61
|
+
These answers come from the HTTP parser, before any middleware.
|
|
62
|
+
|
|
63
|
+
- **Node `http`**: handle `server.on('clientError', (err, socket) => ...)` and write the status line plus the common headers
|
|
64
|
+
yourself (`HPE_HEADER_OVERFLOW` is 431). Do not raise `maxHeaderSize` to make the probe pass; if the limit is higher by design,
|
|
65
|
+
set `maxHeaderBytes` in the config.
|
|
66
|
+
- **Express / Fastify**: Fastify has `clientErrorHandler`; Express uses the Node hook on the `http.Server` it wraps.
|
|
67
|
+
- **uvicorn / gunicorn (FastAPI, Django)**: the server answers parser errors itself. Put the headers in the proxy in front
|
|
68
|
+
(nginx `error_page` with `add_header ... always`), then pass `--raw-exempt "nginx answers parser errors"`.
|
|
69
|
+
- **Go net/http**: there is no parser-error hook; the standard server answers `400 Bad Request` with no custom headers. Set
|
|
70
|
+
the headers in the proxy that fronts it, or wrap the listener, and use `--raw-exempt`.
|
|
71
|
+
- **Rails (Puma)**: Puma answers parser errors itself; add the headers at the proxy.
|
|
72
|
+
- `Content-Length` together with `Transfer-Encoding` must be refused (request smuggling): keep the server's strict parsing on.
|
|
73
|
+
|
|
74
|
+
## resources: `no-body-limit`, `read-before-reject`
|
|
75
|
+
|
|
76
|
+
- Check `Content-Type` before reading the body (415 at the first bytes).
|
|
77
|
+
- A byte limit in the body reader: answer 413 at the limit, then close with a lingering close: keep reading and discard the rest of
|
|
78
|
+
the body up to a cap. An immediate destroy resets the socket and the client loses the 413, notably with large bodies. In Node
|
|
79
|
+
(all versions) a response to a request sent with `Connection: close` (any `agent: false` client, curl `-H 'Connection: close'`,
|
|
80
|
+
HTTP/1.0) destroys the socket as soon as the response finishes, whatever the handler still reads, so consuming the body is not
|
|
81
|
+
enough: write the 413 with its `Content-Length`, keep draining, and call `res.end()` on the request's `close`. Express and
|
|
82
|
+
Fastify answering 413 from their body parser do not do this: against such a client the 413 can be lost to a reset (EPIPE
|
|
83
|
+
/ ECONNRESET), which is a limitation of the client's view, not a missing limit. Plus server timeouts: Node `server.requestTimeout`,
|
|
84
|
+
`server.headersTimeout`; Go `ReadHeaderTimeout`, `ReadTimeout`; uvicorn `--timeout-keep-alive` and a proxy `client_body_timeout`;
|
|
85
|
+
Puma `first_data_timeout`.
|
|
86
|
+
|
|
87
|
+
## concurrency: `duplicates-stored`, `server-error` on the race
|
|
88
|
+
|
|
89
|
+
A `UNIQUE` constraint in the database, and map its violation to 409: Postgres `23505`, MySQL `1062`, SQLite
|
|
90
|
+
`SQLITE_CONSTRAINT_UNIQUE`; check-then-insert in the app is not enough. For a create that is idempotent by design
|
|
91
|
+
(`ON CONFLICT DO NOTHING` answering 200 with the same item) set `idempotentCreate: true` for the route.
|
|
92
|
+
|
|
93
|
+
## faults: `process-died`, `fault-not-reported`, `stack-leak`, `server-error`
|
|
94
|
+
|
|
95
|
+
- Await every database call inside the handler's error boundary; no fire-and-forget writes. A global `unhandledRejection` logger
|
|
96
|
+
is a backstop, not the fix.
|
|
97
|
+
- Answer a fixed body (`{"error":"internal"}`) and log the error server-side: no stack, no driver message, no SQLSTATE.
|
|
98
|
+
- Map "database unavailable" (pool timeout, lock, connection refused) to 503 with `Retry-After`.
|
|
99
|
+
|
|
100
|
+
## auth and csrf: `unauthenticated-access`, `idor`, `csrf`
|
|
101
|
+
|
|
102
|
+
- Deny by default: authentication middleware on the router, then opt routes out (and declare them `public` in the config).
|
|
103
|
+
- Scope every query by owner: `WHERE id = ? AND owner = ?`, and answer 404 (not 403) for objects of another user.
|
|
104
|
+
- CSRF with cookie authentication: check `Origin` (and `Sec-Fetch-Site`) against the app's own origin; `SameSite` alone does not
|
|
105
|
+
cover same-site pages on another port. Django `CsrfViewMiddleware`, Rails `protect_from_forgery` (answers 422 or 403: both
|
|
106
|
+
count as a refusal), Express `csurf` replacements or a custom Origin check, Go `http.CrossOriginProtection` (1.25+).
|
|
107
|
+
- A 302 to the login page for an unauthenticated request is a refusal.
|
|
108
|
+
|
|
109
|
+
## durability: `not-durable`
|
|
110
|
+
|
|
111
|
+
Commit before answering; no in-memory store; migrations run on start.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// http-attack: adversarial probes against an HTTP API running on this machine. Run with --help for the options.
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { main } from './lib/cli.mjs';
|
|
6
|
+
|
|
7
|
+
// Runs when this file is the entry point, also when it is reached through a symlink or a junction.
|
|
8
|
+
// If the paths cannot be resolved the file still runs: a silent exit 0 would read as a clean result.
|
|
9
|
+
const isEntry = () => {
|
|
10
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return true; }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
if (isEntry()) process.exitCode = await main(process.argv.slice(2));
|