Skip to content

Validate the Host header on every request (defense in depth) - #1742

Merged
backnotprop merged 3 commits into
mainfrom
fix/host-header-allowlist
Oct 7, 2026
Merged

backnotprop merged 3 commits into
mainfrom
fix/host-header-allowlist

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Summary

Every Plannotator server now checks the request's Host header before routing anything: plan review (including archive mode), code review, annotate, goal setup, and the servers OpenCode embeds, in both the Bun runtime (packages/server) and the Pi node:http mirror. The check covers the app HTML, every API endpoint, and WebSocket upgrades.

Until now only the session-bridge and host-control endpoints checked Host; the rest relied on isSameOriginOrNoOrigin, which compares Origin with Host and so cannot tell the session's own page from a page served under some other DNS name that resolves to this machine. This adds a server-wide hostname allowlist as an additional layer. isSameOriginOrNoOrigin and the narrower bridge/host-control checks are unchanged.

The rule

One pure predicate, isAllowedRequestHost in packages/shared/request-host.ts (vendored to Pi):

Mode Accepted hostnames
Every mode localhost, *.localhost, 127.0.0.0/8 literals, [::1]
Remote mode (PLANNOTATOR_REMOTE / SSH, wide bind) also any IPv4/IPv6 literal, the configured urlHost (auto resolved through the existing cached lookup), and this machine's os.hostname() (full name and first label)
--tailscale (Bun CLI) also the tailscale serve MagicDNS name, registered once serve reports its URL. tailscale serve forwards the browser's own Host to TCP backends, so the server sees the tailnet name
PLANNOTATOR_ALLOWED_HOSTS (new) comma-separated extra names; .example.com also matches subdomains; * turns the check off
  • A request with no Host header (an HTTP/1.0 non-browser client) is accepted. A malformed Host is refused.
  • The port is not compared. Forwarded ports legitimately arrive with a different port: ssh -L, VS Code/devcontainer auto-forwarding to a loopback-bound server when the local port is taken, and docker -p. A foreign hostname is refused based on the hostname alone.
  • A refused request gets 403 with a short text/plain body (X-Plannotator-Error: host_not_allowed). The body names the hostname and PLANNOTATOR_ALLOWED_HOSTS. It is never a 404, so it can't be confused with the app catch-all.

Compatibility notes

  • VS Code extension: the cookie proxy rewrites Host to the upstream's localhost:<port>, which is a loopback name. vscode-proxy-writes.test.ts passes in both runtimes.
  • Devcontainers / SSH / Docker port forwarding: these arrive as localhost or an IP address, which are accepted.
  • Codespaces/Gitpod forwarded URLs, reverse proxies, Docker service names: these keep their own hostname and now need PLANNOTATOR_ALLOWED_HOSTS. The 403 body says so.
  • Pi: node:http hands an upgrade to every upgrade listener, including the annotate agent terminal's. The Pi adapter therefore intercepts the upgrade event itself, so a refused request never reaches a listener.
  • Not covered, by design:
    • the live-app proxy, which already has its own Host, Origin and WebSocket-origin checks
    • the paste service, which is a public service rather than a session
    • the guides.show local static dev server

Docs

  • CLAUDE.md: a new "Host allowlist" subsection under Server API, a PLANNOTATOR_ALLOWED_HOSTS env row, and a note in the Tailnet section
  • Marketing env reference and the remote/devcontainers guide

Tests

  • packages/shared/request-host.test.ts covers the predicate:
    • loopback forms, *.localhost, look-alike names
    • port mismatch, IPv6 brackets
    • remote IP literals, hostile names, extra hosts / urlHost
    • missing and malformed Host, the env list and *
  • packages/server/request-host.scenarios.ts runs against both runtimes, through packages/server/request-host.test.ts and apps/pi-extension/server/request-host.test.ts, for the plan, review and annotate servers:
    • localhost:<port>, 127.0.0.1:<port> and [::1]:<port> are served.
    • A foreign name gets 403 on /, /api/plan or /api/diff, /api/doc, /api/external-annotations, /api/draft, the decision endpoint (/api/approve or /api/feedback), POST /api/config, and a WebSocket upgrade.
    • Remote mode serves a LAN IPv4, an IPv6 literal, the urlHost and the machine hostname.
    • PLANNOTATOR_ALLOWED_HOSTS works, and * turns the check off.
  • Bun-only: the goal-setup server, and registration of the --tailscale serve name.
  • Pi: request-host-guard.test.ts checks that a refused upgrade never reaches an upgrade listener. I also checked this under real Node 24: the refused upgrade gets the 403 and the allowed upgrade completes.
  • pi-session-bridge.test.ts is updated. A foreign Host is now refused by the server-wide check. The bridge's own loopback-and-port check is still exercised with localhost:<other port>.
  • bun test apps/opencode-plugin apps/pi-extension apps/hook packages: 6266 pass, 0 fail.
  • bun run typecheck: clean.
  • Manual check: built apps/review and apps/hook, then ran bun apps/hook/server/index.ts annotate <tmpfile> with a temp data dir. Host: localhost:<port> and 127.0.0.1:<port> got 200 on / and /api/plan; Host: evil.example:<port> got 403.

Every plan, review, annotate, archive and goal-setup server, in both the
Bun and Pi runtimes, now checks the request's Host header before any route
(app HTML, API, WebSocket upgrades). Loopback names are always accepted;
remote mode also accepts IP literals, the configured urlHost and the
machine's hostname; a --tailscale session accepts its serve name. Other
hostnames get a 403 naming PLANNOTATOR_ALLOWED_HOSTS, the new escape hatch
for proxies and tunnels that forward their own hostname. The port is not
compared, so forwarded ports keep working.
…ntries

- Allow the per-port hostname of a code-server / Coder port proxy, read from
  the `{{port}}` template in the VSCODE_PROXY_URI the IDE sets (any mode:
  such a proxy reaches a loopback-bound server and forwards its own Host).
  Only digits in the template's place match; a path-based template adds
  nothing.
- Remote mode also answers `<first label>.local`, the mDNS name another LAN
  device types (os.hostname() omits it on Linux and often on macOS).
- A `:port` on a PLANNOTATOR_ALLOWED_HOSTS entry is ignored instead of
  silently voiding the entry.
- Docs: Codespaces and VS Code dev tunnels present `Host: localhost:<port>`,
  so they need nothing; drop them as examples of forwarded hostnames and say
  why no provider domain is built in. Note the agent-terminal sidecar.
// A WebSocket upgrade never succeeds: a 403, or (node:http with no
// upgrade listener attached, i.e. no agent terminal) a closed
// socket. The guarded-listener case is request-host-guard.test.ts.
const upgrade = await rawRequest(`${loopbackBase(server)}/api/agent-terminal/pty/x`, {
@backnotprop
backnotprop merged commit 2dff432 into main Oct 7, 2026
28 checks passed
@backnotprop
backnotprop deleted the fix/host-header-allowlist branch October 7, 2026 04:54
backnotprop added a commit that referenced this pull request Oct 7, 2026
…d (no UI yet) (#1745)

* Plannotator Inbox, step 1: server, store, MCP and the inbox subcommand

- `plannotator inbox` (person: start here and open the browser, or open the
  running one), `--background` (detached, prints the URL, never a tab),
  `--no-open`, and `plannotator inbox mcp` (stdio JSON-RPC proxy to /mcp that
  re-reads inbox/inbox.json per message and starts a stopped Inbox).
- packages/server/inbox.ts: Bun-only, always 127.0.0.1, last port first then
  random, never 19432; health, list model, thread, picks, Send, resolve, SSE
  with a cursor, stateless /mcp, token-guarded stop route.
- packages/shared/inbox/: append-only JSONL store (one seq across the Inbox,
  torn-last-line recovery, idempotent sends), registry (0600, token rotated per
  start, kept after exit).
- packages/core/inbox-*.ts: wire shapes (Workspaces' Question + QuestionAnswer
  v1), question parsing with Stopped/Holds up, derived state, Send text.
- MCP tools: send_message, read_thread, resolve_message, wait_for_reply
  (@modelcontextprotocol/server pinned 2.2.0).
- Security: Host allowlist (local rule), no Origin on /mcp, same-origin +
  serverSession on writes, bearer token on connection routes, no CORS.
- uninstall --purge stops a running Inbox first; inbox in the purge list.
- DAST scans a seeded Inbox; the target also allows its own Host name, which
  the Host allowlist (#1742) otherwise refuses.
- Skill reference and AGENTS.md (Server Runtimes exception, Inbox section,
  Inbox Server API table).
- Proofs: live server tests, SDK client over HTTP and stdio, the subcommand as
  real processes, fast-check store properties.

* inbox store: a failed Send never leaves answers sent without a reply; keys never collide

- sendReply writes the questions' sent lines before the reply line. When the
  reply append fails, the questions are put back in memory; at load, a
  question naming a reply that does not exist reads as picked again. Before,
  the person was stuck on question_already_sent and the agent got nothing.
- A failed append marks the file torn, so the next line starts fresh.
- Two same-named roots whose 6-hex project keys collide get a longer hash
  instead of sharing one folder (the second project.json replaced the first).
- The round-trip property now sends arbitrary text (newlines, quotes, NULs,
  U+2028, lone surrogates) and a crafted JSONL-injection body.

* inbox: never start a second writer beside a busy Inbox; cheaper update probe; edge fixes

- A live pid whose port takes the health request but does not answer (a
  paused or blocked Inbox) read as stopped, so the shim and --background
  started a second Inbox on the same store: proven live with SIGSTOP, both
  processes then wrote duplicate seqs. inboxStatus now reports it as busy;
  callers wait for it (20 s, then an error) and never replace it, and
  uninstall --purge treats it as still running.
- Restart to update ran the compiled binary with --version every 60 s
  (~0.2 s CPU, ~440 MB peak RSS each time). It now stats the binary and runs
  it only when its identity changed.
- The detached Inbox runs in inbox/ (not the starter's project folder, which
  Windows would refuse to delete) with windowsHide.
- An HTTP/1.0 request with no Host (allowed by the Host guard) threw in
  new URL() and answered 500; it is read against this server now.
- wait_for_reply on an already-resolved thread answers resolved at once
  instead of holding the call for 50 s.
- DAST target: binaryPath null (no update probe of the bun binary).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants