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
+41 -15
View File
@@ -102,9 +102,9 @@ import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
import { isExplicitlyEnabled, isLoopbackBindHost, buildHostPolicy, type HostPolicy } from './network-auth-policy.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -531,6 +531,14 @@ export class WebServer extends EventEmitter {
};
}
/**
* Current Host/Origin allowlist policy. Read per request so a tunnel started at
* runtime (PUT /api/settings) is reflected without a restart.
*/
private getHostPolicy(): HostPolicy {
return buildHostPolicy(this.host, this.tunnelManager.getUrl());
}
private async setupRoutes(): Promise<void> {
// multipart/form-data: parser is provided by @fastify/multipart (registered
// below). Its parser is a no-op marker that leaves the body on req.raw, so
@@ -547,6 +555,11 @@ export class WebServer extends EventEmitter {
// Cookie plugin (needed for auth session tokens)
await this.app.register(fastifyCookie);
// Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
// before auth so forged cross-site / rebound requests are rejected up front, even
// on the default no-password install. See docs/reports/security-review-2026-06-09.md.
registerHostGuard(this.app, () => this.getHostPolicy());
// Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https);
if (authState) {
@@ -698,24 +711,28 @@ export class WebServer extends EventEmitter {
// parseBody. Shared with the route test harness so test behavior matches prod.
installRouteErrorHandler(this.app);
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
// text/plain is used ONLY by this beacon (navigator.sendBeacon sends text/plain).
// Keep the body as a RAW STRING and parse it inside the handler — a global
// text/plain -> JSON parser would let a cross-site "simple request" (no CORS
// preflight) submit JSON to any route. See security review C2.
let _crashBreadcrumbs = '';
this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
done(null, body);
});
this.app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => {
try {
done(null, JSON.parse(body as string));
} catch {
done(null, { data: body });
}
done(null, body);
});
this.app.post('/api/crash-diag', (req, reply) => {
_crashBreadcrumbs = String((req.body as { data?: string })?.data || '');
const raw = typeof req.body === 'string' ? req.body : '';
let data = raw;
try {
const parsed = JSON.parse(raw) as { data?: unknown };
if (parsed && typeof parsed.data === 'string') data = parsed.data;
} catch {
/* not JSON — treat the raw beacon text as the breadcrumbs */
}
_crashBreadcrumbs = String(data || '');
reply.code(204).send();
});
this.app.get('/api/crash-diag', (_req, reply) => {
@@ -738,7 +755,7 @@ export class WebServer extends EventEmitter {
registerPlanRoutes(this.app, ctx);
registerClipboardRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWsRoutes(this.app, ctx);
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
}
/**
@@ -1699,6 +1716,15 @@ export class WebServer extends EventEmitter {
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
// Anti-DNS-rebinding Host allowlist is always on. Localhost, any bare IP, the
// bind host, *.ts.net / *.trycloudflare.com / *.cfargotunnel.com, and the active
// managed tunnel are accepted automatically; add any other domain you front this
// with (e.g. a custom reverse-proxy host) via CODEMAN_ALLOWED_HOSTS=host1,.suffix.
const extraAllowed = (process.env.CODEMAN_ALLOWED_HOSTS || '').trim();
if (extraAllowed) {
console.log(` Host allowlist also accepts: ${extraAllowed}`);
}
// Codeman binds loopback (127.0.0.1) by default, which is safe out of the box.
// If the user opts into a non-loopback bind (e.g. --host 0.0.0.0) WITHOUT a
// password we no longer refuse to start — that surprised people whose setups