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
+8 -8
View File
@@ -802,13 +802,13 @@ Object.assign(CodemanApp.prototype, {
const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false });
if (a.type === 'tool') {
const toolDetail = this.getToolDetailExpanded(a.tool, a.input, a.fullInput, a.toolUseId);
return `<div class="subagent-activity tool" data-tool-use-id="${a.toolUseId || ''}">
return `<div class="subagent-activity tool" data-tool-use-id="${escapeHtml(a.toolUseId || '')}">
<span class="time">${time}</span>
<span class="icon">${this.getToolIcon(a.tool)}</span>
<span class="name">${a.tool}</span>
<span class="detail">${toolDetail.primary}</span>
<span class="name">${escapeHtml(a.tool)}</span>
<span class="detail">${escapeHtml(toolDetail.primary)}</span>
${toolDetail.hasMore ? `<button class="tool-expand-btn" onclick="app.toggleToolParams('${escapeHtml(a.toolUseId)}')">▶</button>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${a.toolUseId}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
${toolDetail.hasMore ? `<div class="tool-params-expanded" id="tool-params-${escapeHtml(a.toolUseId)}" style="display:none;"><pre>${escapeHtml(JSON.stringify(a.fullInput || a.input, null, 2))}</pre></div>` : ''}
</div>`;
} else if (a.type === 'tool_result') {
const icon = a.isError ? '❌' : '📄';
@@ -818,7 +818,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="subagent-activity tool-result ${statusClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="name">${a.tool || 'result'}</span>
<span class="name">${escapeHtml(a.tool || 'result')}</span>
<span class="detail">${escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} else if (a.type === 'progress') {
@@ -830,7 +830,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="subagent-activity progress${hookClass}">
<span class="time">${time}</span>
<span class="icon">${icon}</span>
<span class="detail">${displayText}</span>
<span class="detail">${escapeHtml(displayText)}</span>
</div>`;
} else if (a.type === 'message') {
const preview = a.text.length > 100 ? a.text.substring(0, 100) + '...' : a.text;
@@ -1400,7 +1400,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="activity-line">
<span class="time">${time}</span>
<span class="tool-icon">${this.getToolIcon(a.tool)}</span>
<span class="tool-name">${a.tool}</span>
<span class="tool-name">${escapeHtml(a.tool)}</span>
<span class="tool-detail">${escapeHtml(this.getToolDetail(a.tool, a.input))}</span>
</div>`;
} else if (a.type === 'tool_result') {
@@ -1411,7 +1411,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="activity-line result-line${statusClass}">
<span class="time">${time}</span>
<span class="tool-icon">${icon}</span>
<span class="tool-name">${a.tool || '→'}</span>
<span class="tool-name">${escapeHtml(a.tool || '→')}</span>
<span class="tool-detail">${escapeHtml(preview)}${sizeInfo}</span>
</div>`;
} else if (a.type === 'progress') {