Files
@aaronjmars 2be93108d0 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>
2026-05-29 11:00:20 +08:00
..