Commit 2be9310
fix(security): validate Host header on /api/* to block DNS rebinding (#61)
* fix(security): validate Host header on /api/* to block DNS rebinding
`next dev` and `next start` bind to 0.0.0.0 by default. A malicious
page can DNS-rebind an attacker-controlled name (`attacker.example` →
`127.0.0.1`) and POST to `/api/convert`, `/api/deploy`, etc. through
the user's browser — `/api/convert` spawns the local agent CLI with
maximally permissive flags, so a successful forged-Host POST is
unauthenticated RCE via the agent.
Add a Next middleware that gates every `/api/*` request on a
Host-header allowlist. Defaults to loopback only (`127.0.0.1`,
`localhost`, `::1`, any port). Two operator knobs:
- `HTML_ANYTHING_ALLOWED_HOSTS=host1,host2,…` — extend the allowlist
for LAN / mDNS / `.local` setups.
- `HTML_ANYTHING_ALLOW_ANY_HOST=1` — bypass entirely, for when a
trusted reverse proxy is terminating Host upstream. Loudly insecure
by design; not the default.
Restructured for the workspace layout per maintainer guidance on PR #61:
- `next/src/middleware.ts` — the Next middleware (runs on `/api/:path*`)
- `next/src/lib/security/host-validation.ts` — pure validator + env wrapper
- `next/src/lib/security/host-validation.test.ts` — vitest, runs under
`pnpm -F @html-anything/next test` (`src/**/*.test.ts` glob)
- `e2e/ui/host-validation.spec.ts` — Playwright, runs under
`pnpm -F @html-anything/e2e test`. Covers the accept-loopback path
so the default `next start -p 3317` UX still works, plus the reject
path for attacker.example / subdomain tricks / forged POSTs against
/api/convert + /api/deploy/config.
- README.md — new `## Security` section documenting when to use the
defaults vs `ALLOWED_HOSTS` vs `ALLOW_ANY_HOST=1` (operator story).
Root `package.json` left untouched (zero scripts, workspace metadata
only) per the workspace rule in `AGENTS.md`. No source under root
`src/` / `app/`, no Playwright outside `e2e/`.
* fix(security): tighten loopback allowlist + pin middleware to Node runtime
Addresses the three findings from @PerishCode's review on #61:
1. **Drop `0.0.0.0` from the loopback allowlist** — on macOS/Linux it routes
to the local machine, and pre-fix Chrome (< 128) lets a public page fetch
`http://0.0.0.0:<port>` directly, bypassing DNS rebinding entirely.
`LOOPBACK_HOSTS.has("0.0.0.0")` would have returned true and reached
`/api/convert` (the agent-spawn RCE path). The justifying comment ("some
test runners send 0.0.0.0 as Host") doesn't hold for this repo —
`e2e/playwright.config.ts` dials `127.0.0.1:3317`, no test sent 0.0.0.0.
2. **Drop bare `::1` from the loopback allowlist** — `stripPort("::1")`
produces `":"` (the last-colon-trailing-digit branch), so bare `::1`
could never match anyway. Only the bracketed `[::1]` form is reachable,
and that's what browsers and HTTP/2 `:authority` actually send.
Added a `stripPort("::1") === ":"` assertion to document the behavior.
3. **Pin middleware to Node runtime** — `export const runtime = "nodejs"`
in `middleware.ts`. The `HTML_ANYTHING_ALLOWED_HOSTS` /
`HTML_ANYTHING_ALLOW_ANY_HOST` env knobs are read by `isRequestHostAllowed`
inside the middleware; on Edge runtime Next can inline `process.env.*`
references at build time, which would silently fail to extend the
allowlist (lock-out) or fail to disable the gate (false reassurance).
Node runtime middleware (Next 15.2+; this repo is on 16.2.6) reads env
per-request. README `## Security` updated to call out the choice.
Unit tests extended:
- `stripPort("::1") === ":"` — documents the IPv6-bare mangle.
- `isAllowedHost("0.0.0.0") === false` and `isAllowedHost("0.0.0.0:3317") === false`.
- `isAllowedHost("::1") === false` — bare unbracketed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(e2e): provide baseURL fallback under exactOptionalPropertyTypes
@PerishCode flagged that `e2e/ui/host-validation.spec.ts` fails the
"Typecheck e2e" CI step. The `baseURL` fixture is `string | undefined`,
e2e/tsconfig.json sets `exactOptionalPropertyTypes: true`, and
`newContext`'s `baseURL?: string` rejects an explicit undefined under
that flag — tsc errored at all seven call sites.
Hoists a `DEFAULT_BASE_URL` matching `webServer.url` in
e2e/playwright.config.ts (`http://127.0.0.1:3317`) and uses
`baseURL ?? DEFAULT_BASE_URL` at every newContext site. Runtime behavior
is unchanged when baseURL is set (which it always is when Playwright
runs the suite); the fallback only satisfies the type checker for the
undefined branch.
Verified locally:
pnpm -F @html-anything/e2e typecheck # clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(e2e): send single-space Host to deterministically test empty-Host branch
@PerishCode flagged that `Host: ""` is at the mercy of Playwright's
header serialization — if the empty value is dropped, the request goes
out with the default loopback Host and the test passes for the wrong
reason; if it's transmitted, the validator deterministically returns
403. Outcome depended on undefined HTTP-stack behavior.
Switched to `Host: " "`. Per RFC 7230 a single-space header value is
transmitted, the receiving parser strips the surrounding OWS, and the
server sees `host: ""` deterministically. Either way the validator's
`stripPort.trim()` reduces it to "" and `isAllowedHost("")` returns
false → 403 — the test now exercises the empty-Host branch it claims
to, with no flake surface.
Verified locally: `pnpm -F @html-anything/e2e typecheck` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 145a40e commit 2be9310
5 files changed
Lines changed: 459 additions & 0 deletions
File tree
- e2e/ui
- next/src
- lib/security
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
440 | 440 | | |
441 | 441 | | |
442 | 442 | | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
443 | 455 | | |
444 | 456 | | |
445 | 457 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
0 commit comments