2be93108d0
* 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>