Files
Codeman/src/web/network-auth-policy.ts
T
arkonandClaude Opus 4.8 c669518ba0 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>
2026-06-09 03:19:51 +02:00

131 lines
5.0 KiB
TypeScript

import { isIP } from 'node:net';
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, '$1');
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
return true;
}
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
return true;
}
return normalized.startsWith('::ffff:127.');
}
/**
* Hostname suffixes that are always accepted by the Host/Origin allowlist. These
* are namespaces an external attacker cannot register DNS-rebinding records under
* (tailscale MagicDNS, Cloudflare quick/named tunnels), so accepting them keeps
* the project's documented tunnel access paths working without reopening the
* rebinding hole. Extend per-deployment via CODEMAN_ALLOWED_HOSTS.
*/
export const DEFAULT_TRUSTED_HOST_SUFFIXES = ['.ts.net', '.trycloudflare.com', '.cfargotunnel.com'];
/** Policy inputs for the anti-DNS-rebinding Host allowlist + cross-site Origin guard. */
export interface HostPolicy {
/** The host the server is bound to (e.g. '127.0.0.1', '0.0.0.0', or a hostname). */
bindHost: string;
/** Extra allowed hosts: exact lowercased names, or a leading-dot '.suffix' for suffix matches. */
allowedHosts: string[];
/** Hostname of the currently-active Codeman-managed tunnel, if any. */
tunnelHost?: string | null;
}
/**
* Extract the lowercased hostname from a Host/authority value, stripping the port
* and IPv6 brackets. Returns null for empty/garbage input.
*/
export function parseAuthorityHostname(authority: string | undefined): string | null {
if (!authority) return null;
let h = authority.trim();
if (!h) return null;
if (h.startsWith('[')) {
// [::1] or [::1]:3000
const end = h.indexOf(']');
if (end === -1) return null;
return h.slice(1, end).toLowerCase() || null;
}
// host:port — only treat a single trailing colon as a port separator so a
// bracketless IPv6 literal (multiple colons) is left intact.
const first = h.indexOf(':');
if (first !== -1 && first === h.lastIndexOf(':')) {
h = h.slice(0, first);
}
return h.toLowerCase() || null;
}
/** Build a HostPolicy from the bind host, CODEMAN_ALLOWED_HOSTS, and an active tunnel URL. */
export function buildHostPolicy(bindHost: string, tunnelUrl?: string | null): HostPolicy {
const allowedHosts = (process.env.CODEMAN_ALLOWED_HOSTS || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
let tunnelHost: string | null = null;
if (tunnelUrl) {
try {
tunnelHost = new URL(tunnelUrl).hostname.toLowerCase();
} catch {
tunnelHost = null;
}
}
return { bindHost, allowedHosts, tunnelHost };
}
function matchesHost(hostname: string, policy: HostPolicy): boolean {
// localhost is reserved (always resolves to loopback, not rebindable).
if (hostname === 'localhost') return true;
// Any IP literal: a literal address cannot be the target of DNS rebinding — the
// browser connected straight to it, there is no name to re-point.
if (isIP(hostname) !== 0) return true;
const bind = parseAuthorityHostname(policy.bindHost);
if (bind && hostname === bind) return true;
if (policy.tunnelHost && hostname === policy.tunnelHost) return true;
for (const suffix of DEFAULT_TRUSTED_HOST_SUFFIXES) {
if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) return true;
}
for (const entry of policy.allowedHosts) {
if (entry.startsWith('.')) {
if (hostname === entry.slice(1) || hostname.endsWith(entry)) return true;
} else if (hostname === entry) {
return true;
}
}
return false;
}
/**
* True if a request's Host header is allowed. Blocks DNS-rebinding: a custom
* domain rebound to a loopback/LAN address still carries its own name in Host,
* which will not be in the allowlist.
*/
export function isAllowedRequestHost(hostHeader: string | undefined, policy: HostPolicy): boolean {
const hostname = parseAuthorityHostname(hostHeader);
if (!hostname) return false;
return matchesHost(hostname, policy);
}
/**
* True if a request's Origin is allowed for a state-changing / WebSocket request.
* A MISSING Origin is allowed: non-browser clients (curl, Claude Code hooks) omit
* it, while browsers always attach it on cross-origin state-changing/WS requests —
* so a forged cross-site request is caught while local automation keeps working.
* The opaque origin 'null' (sandboxed iframe, data: URL) is rejected.
*/
export function isAllowedRequestOrigin(originHeader: string | undefined, policy: HostPolicy): boolean {
if (originHeader === undefined || originHeader === '') return true;
if (originHeader === 'null') return false;
try {
return matchesHost(new URL(originHeader).hostname.toLowerCase(), policy);
} catch {
return false;
}
}