fix(security): block DNS rebinding + cross-site CSRF + subagent-panel XSS

Adds an always-on Host-header allowlist and a cross-site Origin/CSRF guard,
hardens the text/plain body parser, validates the WebSocket upgrade origin,
and escapes AI-derived fields in the subagent panel. Closes the two
CRITICALs and 5 HIGHs from the 2026-06-09 adversarial security review.

- C1: no Host allowlist -> DNS rebinding drove the full API (RCE) on the
  default no-auth loopback install. New registerHostGuard rejects rebound
  custom domains; allows loopback, any IP literal, the bind host,
  *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, the active managed
  tunnel, and CODEMAN_ALLOWED_HOSTS.
- C2: a global text/plain parser JSON-parsed every body, enabling cross-site
  simple-request CSRF. Parser now keeps the raw string; /api/crash-diag
  self-parses; the global Origin guard rejects cross-site state changes.
- H1/H3/H6: self-update, session create/input, and settings/tunnel toggles
  were CSRF-triggerable -> now covered by the Origin guard.
- H4: the subagent activity panel injected raw AI tool names/inputs into
  innerHTML (executed under CSP 'unsafe-inline'). All sinks now escapeHtml'd.
- H5: the WebSocket upgrade had no Origin/Host check (CSWSH) -> now validated.

A missing Origin is allowed so curl/CLI and Claude Code hooks keep working;
custom reverse-proxy domains need CODEMAN_ALLOWED_HOSTS=host,.suffix.

Deferred: H2 (self-update tag signing, needs signing infra) and CSP
'unsafe-inline' removal (needs a nonce migration).

Tests: test/network-host-guard.test.ts (19), test/routes/ws-routes.test.ts
updated. Report: docs/reports/security-review-2026-06-09.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-09 03:19:51 +02:00
co-authored by Claude Opus 4.8
parent 3a56ea4978
commit c669518ba0
8 changed files with 483 additions and 25 deletions
+35
View File
@@ -12,6 +12,7 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
import {
AUTH_SESSION_TTL_MS,
MAX_AUTH_SESSIONS,
@@ -157,6 +158,40 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return state;
}
/** Methods that don't change server state and so skip the cross-site Origin check. */
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
* Register the anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard.
*
* This protects the API even on the default no-password install, where there is no
* cookie/credential to gate on. It must be registered BEFORE the auth middleware so
* forged cross-site or DNS-rebound requests are rejected up front. `getPolicy` is
* evaluated per request so a tunnel started at runtime is reflected immediately.
*
* - Every request: the `Host` header must be in the allowlist (blocks DNS rebinding,
* where a custom domain is rebound to 127.0.0.1 but still sends its own name).
* - State-changing methods: the `Origin` (when the client sends one — i.e. a browser)
* must be same-site (blocks cross-site CSRF, including the text/plain simple-request
* trick). Non-browser clients (curl, Claude Code hooks) omit Origin and pass.
*
* WebSocket upgrades are validated separately in the ws route handler.
*/
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
app.addHook('onRequest', (req, reply, done) => {
const policy = getPolicy();
if (!isAllowedRequestHost(req.headers.host, policy)) {
reply.code(403).send('Forbidden: host not allowed');
return;
}
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
reply.code(403).send('Forbidden: cross-site request blocked');
return;
}
done();
});
}
/**
* Register security headers and CORS middleware on every response.
*/