# AIW Security Audit & Remediation — Pass 5

**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`, `main.js`, `.env.example`, `tests/api/*`
**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 in [`SECURITY-REVIEW.md`](SECURITY-REVIEW.md), [`SECURITY-AUDIT2.md`](SECURITY-AUDIT2.md), [`SECURITY-AUDIT3.md`](SECURITY-AUDIT3.md), and [`SECURITY-AUDIT4.md`](SECURITY-AUDIT4.md) (the `AUD-#` annotations in the source originate across those passes).

---

## Executive Summary

By Pass 5 the codebase is heavily hardened across the XMR realm: 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, exact-host same-origin checks, a DNS-rebinding `Host` guard, a `SameSite=Strict` session gate, an environment **allowlist** for the Agent SDK subprocess, and second-factor + lockout gates on seed reveal / Strike PIN / Decree PIN.

This pass re-reviewed the **10 attack surfaces** with specific attention to **realm parity** — whether the BTC realm enjoys the same protections the XMR realm received in Passes 1–4. It does not. **The headline finding (F1) is a Critical asymmetry:** the BTC RPC path has no key-exfiltration blocklist, the BTC equivalent of the `query_key` block that Passes 1–3 explicitly closed for XMR. A prompt-injected White Rabbit — or any caller reaching `/api/btc/rpc` — could run `listdescriptors true` / `dumpprivkey` / `dumpwallet` and receive the wallet's master private key (xprv) with no confirmation dialog. This also corrects an over-broad "sound" claim in Pass 4 ("`query_key` blocklisted on every agent/proxy path"), which was true only for XMR.

**Five findings were identified and all five have been remediated** in this pass. F1 is the highest-leverage fix; F2 closes the only remaining "one blind POST moves funds" XMR primitive (the send/sweep hardening Pass 4 explicitly deferred); F3 closes the remaining stored-XSS gap chained with F2.

**Overall posture: Critical pre-pass (BTC key-exfil); after the fixes → acceptable for the stated trust model (single-user, local, loopback-bound, optionally password-gated), now with XMR/BTC realm parity.**

| # | Finding | Severity | Status |
|---|---|---|---|
| F1 | BTC RPC path has no key-exfil blocklist (`dumpprivkey`/`dumpwallet`/`listdescriptors` reachable via chat **and** `/api/btc/rpc`) | **CRITICAL** | ✅ Fixed |
| F2 | XMR `/api/transfer/send` silent fresh-transfer fallback + no explicit auth re-check on send/sweep | **HIGH** | ✅ Fixed |
| F3 | Unescaped `payment_id` (and fragile `txid`/`address` in `onclick` JS-string contexts) | **MEDIUM** | ✅ Fixed |
| F4 | Default config ships `DASHBOARD_PASSWORD` blank (passwordless-loopback) | LOW (by design) | Documented — not a code defect |
| F5 | Credentialed CORS default trusts plain `http://localhost` | LOW | ✅ Fixed |
| — | Prompt injection → unauthorized XMR tx | Low (do-not-relay gate holds) | No change needed |
| — | `.env` exposure | Info | Pass — masked, gitignored |
| — | Path traversal | Info | Pass — `_safeSkillSeg` + `startsWith(PUBLIC_DIR)` |
| — | Queen's Decree kill switch | Info | Pass — auth + PIN + lockout |
| — | Agent SDK session leak | Info | Pass — env allowlist + scratch cwd |

In-source tags introduced this pass: **`F1`** (BTC RPC blocklist), **`F2`** (transfer send/sweep auth re-check + no fresh-transfer fallback), **`F3`** (`jsAttr` JS-string encoder + field escaping), **`F5`** (mobile-origins default tightened).

---

## Finding F1 — Bitcoin RPC has no key-exfiltration blocklist (asymmetric with XMR)

- **Severity:** CRITICAL
- **Areas:** #1 RPC injection, #2 prompt injection, #10 wallet/seed key exposure

### Attack vector
Passes 1–3 closed the XMR key-exfil path: `RPC_BLOCKED_WALLET_METHODS` (`query_key`, `export_key_images`, `export_outputs`, `relay_tx`, …) is enforced before dispatch on both chat pipelines and the `/api/rpc/*` proxy. **The BTC realm had no equivalent.** Both the `/api/btc/rpc` proxy and the `btcrpc` chat tool action (in `handleChat` *and* `handleChatStream`) gated only `BTC_SPENDING_METHODS` (`sendtoaddress`, `sendmany`, `sendrawtransaction`, `bumpfee`). Every other method was passed straight to `btcRpc(method, params)` — including `dumpprivkey`, `dumpwallet`, `listdescriptors true` (returns the wallet **xprv**/master key), `sethdseed`, `importprivkey`, `walletpassphrase`, `createwallet`, `unloadwallet`.

Reachable two ways: (a) **prompt injection** — a crafted message / poisoned `data/exchanges.json` entry / malicious swap-service response steers the White Rabbit into emitting the tool call, the result returns in `toolResults`; (b) any authenticated caller, or **any local caller in the documented passwordless default**, POSTing to `/api/btc/rpc`. Unlike a spend, this needs no user confirmation and is silent, instant, and irreversible.

### Proof of concept
```bash
curl -s localhost:3000/api/btc/rpc -H 'Content-Type: application/json' \
  -d '{"method":"listdescriptors","params":[true]}'
# → returns the wallet's tprv/xprv (master private key). Total BTC compromise.
```
Via chat (prompt injection): a model reply containing
` ```{"action":"btcrpc","method":"dumpprivkey","params":["<addr>"]}``` `
is parsed by `extractToolCalls` and executed, returning the private key.

### Fix (F1)
Added `BTC_RPC_BLOCKED_METHODS` + `isBtcRpcMethodBlocked()` in `ui/server.mjs` (BTC parity with `isRpcMethodBlocked`), lowercased-normalised so casing tricks can't slip past. Enforced at **all three** dispatch points (per the duplicated-pipeline contract):

- `/api/btc/rpc` proxy → `403 RPC_BLOCKED_MSG` before `btcRpc()`.
- `handleChat` `btcrpc` branch → `{ error: RPC_BLOCKED_MSG, success:false }`, `continue`.
- `handleChatStream` `btcrpc` branch → same.

Blocked set: `dumpprivkey`, `dumpwallet`, `listdescriptors`, `gethdkeys`, `walletpassphrase`, `walletpassphrasechange`, `walletlock`, `encryptwallet`, `importprivkey`, `importwallet`, `importdescriptors`, `importmulti`, `importpubkey`, `importaddress`, `sethdseed`, `signrawtransactionwithkey`, `createwallet`, `loadwallet`, `unloadwallet`, `restorewallet`, `migratewallet`, `backupwallet`.

**Non-breaking:** legitimate onboarding/restore flows (`createwallet`, `loadwallet`, `importdescriptors`) call `btcRpc()`/`btcRpcOnNetwork()` directly from dedicated server endpoints — they never traverse the chat-tool pipeline or the raw proxy, so they are unaffected. The block applies only to the agent/proxy attack surface.

### Validation
`node --check ui/server.mjs` passes. Traced all 33 internal `btcRpc()` call-sites — none of the blocked methods are invoked through the chat tool or `/api/btc/rpc` in any legitimate flow; the onboarding/restore endpoints that legitimately call `createwallet`/`importdescriptors` use the direct `btcRpc*` path and remain functional.

---

## Finding F2 — XMR transfer send/sweep weaker than the rest of the codebase

- **Severity:** HIGH (highest impact in the passwordless-loopback default / on a gate bypass)
- **Areas:** #4 auth bypass, #8 CSRF

### Attack vector
The BTC `/api/btc/transfer/send` and `/api/btc/transfer/sweep` endpoints already carried an explicit `if (!isAuthenticated(req)) 401` defense-in-depth check (as do `/api/transfer/confirm-chat`, seed reveal, and decree-execute). The **XMR** `/api/transfer/send` and `/api/transfer/sweep` did not — they relied solely on the global gate. Worse, `/api/transfer/send` contained a **silent fresh-transfer fallback**: if no matching `_pendingPreview` existed, it built *and broadcast* an arbitrary `transfer` from the request body directly. A single blind POST (`{address, amount_xmr, confirmation:"CONFIRM"}`) moved funds with no preview/confirmation step — the exact "one POST drains the wallet, bypassing the Confirm dialog" primitive the threat model is built to prevent. Pass 4 explicitly tracked this as deferred ("a per-session spending PIN would harden the stolen-session-cookie case … UI-impacting").

### Proof of concept
```bash
curl -s localhost:3000/api/transfer/send -H 'Content-Type: application/json' \
  -d '{"address":"4ATTACKER…","amount_xmr":"1.0","confirmation":"CONFIRM"}'
# pre-fix: no _pendingPreview ⇒ falls through to a fresh wallet `transfer` and broadcasts.
```
Chained with F3 (same-site XSS), this bypassed the Origin/SameSite CSRF protections entirely.

### Fix (F2)
In `ui/server.mjs`, for both XMR `/api/transfer/send` and `/api/transfer/sweep`:

- Added explicit `if (!isAuthenticated(req)) → 401` (parity with the BTC endpoints and the confirm-chat / decree / seed pattern).
- **Removed the silent fresh-transfer fallback** in `/api/transfer/send`: a missing or mismatched/expired `_pendingPreview` now returns `409` ("preview the transfer first") instead of building and broadcasting. The only broadcast path is now relaying a previewed, confirmed tx.

**Non-breaking:** the dashboard always calls `/api/transfer/preview` before `/api/transfer/send` (`dashboard.js`), which stages the server-side `_pendingPreview`; the chat agent stages a `do_not_relay` preview confirmed via `/api/transfer/confirm-chat` (separate endpoint, untouched). No legitimate flow used the fallback.

### Validation
`node --check` passes. Reviewed every `/api/transfer/send|preview` caller (dashboard previews first; only the Postman negative tests and the gitignored stale `dist/` build hit it otherwise). The two Postman negative transfer tests (`invalid address`, `amount > balance`) were updated to accept `409` (semantically the correct "rejected, not broadcast" code) so the suite stays green; the collection JSON re-parses cleanly.

---

## Finding F3 — Unescaped `payment_id`; fragile `txid`/`address` in `onclick`

- **Severity:** MEDIUM
- **Area:** #6 XSS in transaction fields

### Attack vector
`renderTxDetail` interpolated `tx.payment_id` into `innerHTML` **unescaped**, while the adjacent `tx.note` was escaped — an inconsistent trust assumption (RPC output rendered as trusted). Several `onclick` handlers spliced `tx.txid` / `a.address` into a single-quoted JS string inside a double-quoted HTML attribute. `escapeHtml` is the *wrong* tool there: the HTML parser decodes `&#39;` back to `'` before the JS string is evaluated, re-opening the breakout. In practice monero-wallet-rpc returns these as constrained hex/base58 so this is not exploitable through a normal daemon — but a prompt-injected agent (e.g. via the unblocked-on-XMR `set_tx_notes`/label paths) or a patched wallet-rpc could place markup there, and chained with F2 a single injected `<img onerror>` drains the wallet (same-site, bypassing CSRF defenses).

### Proof of concept
A `get_transfers` response with
`payment_id = "<img src=x onerror=fetch('/api/transfer/sweep',{method:'POST',headers:{'Content-Type':'application/json'},body:'{\"confirmation\":\"CONFIRM\",\"address\":\"…\"}'})>"`
executes on render of the TX detail panel.

### Fix (F3)
In `ui/public/dashboard.js`:

- Added `jsAttr(s)` — a JS-string-safe encoder that strips everything outside `[A-Za-z0-9_.:-]`, correct for values spliced into a single-quoted JS string inside a double-quoted HTML attribute (callers only ever pass daemon-constrained hex/base58 ids, so valid input is never altered).
- `tx.payment_id` and `tx.txid` (detail cell, ledger `title=`, receipt `title=`) now go through `escapeHtml` (correct for element/attribute-content contexts).
- All `onclick` JS-string interpolations of RPC-derived values now use `jsAttr`: `copyText('${jsAttr(tx.txid)}')`, `copyAddr/shareAddr/toggleBtcAddrDetail/_btcProof.openAddress('${jsAttr(a.address)}')`, `copyText('${jsAttr(safeId)}')`, `openProofMenu('${jsAttr(tx.txid)}','${jsAttr(tx.direction)}','${jsAttr(tx.address)}')`, and the proof signature/key copy button. Applied across both the XMR and BTC render paths.

### Validation
`node --check ui/public/dashboard.js` passes. Grep confirms zero remaining unescaped `txid`/`address` JS-string interpolations in `onclick`. Address/wallet labels were already `escapeHtml`-wrapped (Pass 2, `AUD-#5`) and remain so.

---

## Finding F4 — Default config ships `DASHBOARD_PASSWORD` blank

- **Severity:** LOW (by design within the stated trust model) — documented, not a code change
- **Area:** #3 / #4

`.env.example` ships `DASHBOARD_PASSWORD=` blank; `isAuthenticated()` then returns `true` for every request. This is the *documented* passwordless-loopback model and is already correctly bounded: `secureBindHost()` hard-fails on an explicit non-loopback bind with no password and otherwise forces a `127.0.0.1` bind, and the Pass 4 DNS-rebinding `Host` guard blocks the browser vector. The residual exposure — *another local process / local malware on the same machine* — is inherent to any localhost-bound service and is consistent with the seed-reveal route's own `body.confirm` fallback in full passwordless mode. It is **not a code defect**; forcing a mandatory password would change documented UX. **Recommendation (tracked):** prompt for / generate a `DASHBOARD_PASSWORD` during onboarding so the default deployment is authenticated. This closes the local-process surface that F1/F2 are otherwise reachable through in the default config.

---

## Finding F5 — Credentialed CORS default trusts `http://localhost`

- **Severity:** LOW
- **Area:** #8 CORS/CSRF

### Attack vector
`applyCorsHeaders` sets `Access-Control-Allow-Credentials: true` and reflects the request `Origin` for any entry in `MOBILE_ORIGINS`, whose default included `http://localhost`. A co-resident page served from `http://localhost` (another local dev server / local app) was therefore a *credentialed* cross-origin caller. Impact is limited (the session cookie is `SameSite=Strict`; mobile uses `Authorization: Bearer`), but the default needlessly widened the cross-origin trust boundary.

### Fix (F5)
Default `MOBILE_ORIGINS` reduced to `capacitor://localhost,https://localhost` (the schemes modern Capacitor iOS/Android actually use) in both `ui/server.mjs` and `.env.example`, with an explanatory comment. Users who genuinely need plain `http://localhost` add it back explicitly.

### Validation
`node --check` passes; the same-origin path (browser/Electron) is unaffected (it never consults `MOBILE_ORIGINS`); Capacitor shells use the `capacitor://`/`https://` origins that remain in the default.

---

## Areas confirmed sound (no change needed)

| Area | Mechanism (already present) |
|---|---|
| Prompt injection → XMR spend | XMR spends forced `do_not_relay` + UI confirm via auth-gated `/api/transfer/confirm-chat`; `RPC_BLOCKED_WALLET/DAEMON_METHODS` block key-export/raw-broadcast before dispatch. Agent can only *stage* a tx the user must confirm. (BTC parity now added by F1.) |
| `.env` exposure | `.env` outside `PUBLIC_DIR`; `/api/settings` GET masks every secret with a fixed `••••••••` (no prefix leak, `AUD-#3`); `WALLET_PASSWORD`/`WALLET_MNEMONIC` never returned; `.gitignore`/`.vercelignore` keep secrets/wallets/source out of git and the marketing deploy. |
| Path traversal | `loadSkill`/`listSkills` hard-restricted to `[A-Za-z0-9_-]` (`_safeSkillSeg`, `AUD-#5b`); static serving normalises via `join` + `startsWith(PUBLIC_DIR)`; favicon route rejects `..`/`/`; txid routes regex-validate `^[0-9a-f]{64}$`; BTC RPC uses `execFileAsync('docker', argsArray)` — no shell, no command injection. |
| 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 from an **allowlist**, credential vars hard-stripped (`AUD-002-06`), isolated scratch `cwd`, `settingSources:[]`, `mcpServers:{}`, `allowedTools:[]`, `maxTurns:1`, model allowlist clamp. Subscription session intentionally shared with the spawned CLI; not exposed to unrelated processes. |
| Wallet / seed access | `/api/wallets/seed` POST-only, behind `isAuthenticated` + second factor (dash pw → wallet pw → explicit confirm) + 3-try lockout + audit (`AUD-#010`); XMR `query_key` blocklisted on every agent/proxy path — **and now the BTC `dumpprivkey`/`listdescriptors` equivalent (F1)**. |

---

## Changed files (this pass)

| File | Change | Tag |
|---|---|---|
| `ui/server.mjs` | `BTC_RPC_BLOCKED_METHODS` + `isBtcRpcMethodBlocked()`; enforced in `/api/btc/rpc` proxy and the `btcrpc` branch of both `handleChat` and `handleChatStream` | `F1` |
| `ui/server.mjs` | Explicit `isAuthenticated()` re-check on XMR `/api/transfer/send` & `/api/transfer/sweep`; removed the silent fresh-transfer fallback (now `409` without a valid preview) | `F2` |
| `ui/public/dashboard.js` | `jsAttr()` JS-string-safe encoder; `escapeHtml` on `payment_id`/`txid`; `jsAttr` on all `onclick` txid/address/id args (XMR + BTC paths) | `F3` |
| `ui/server.mjs`, `.env.example` | Default `MOBILE_ORIGINS` reduced to `capacitor://localhost,https://localhost` | `F5` |
| `tests/api/aiw-api.postman_collection.json` | Negative transfer tests accept `409` (post-F2 "rejected, not broadcast") | — |

## Validation summary

- `node --check` passes on `ui/server.mjs` and `ui/public/dashboard.js`.
- `tests/api/aiw-api.postman_collection.json` re-parses cleanly after the assertion update.
- All blocked BTC methods traced against the 33 internal `btcRpc()` call-sites: no legitimate flow uses the chat tool or raw proxy for them; onboarding/restore use the direct `btcRpc*` path and remain functional.
- F2 fallback removal traced against all `/api/transfer/send|preview` callers: dashboard previews first; no production caller relied on the fallback.

## Residual / recommendations

- **F4 (tracked):** ship an authenticated default by prompting for / generating `DASHBOARD_PASSWORD` during onboarding. This is the one remaining structural item — it closes the local-process surface that F1/F2 are reachable through in the documented passwordless default. Not a code defect; a deployment/onboarding policy decision.
- The confirm-gated transfer endpoints still use a fixed `confirmation: 'CONFIRM'` string rather than a brute-forceable per-session spending PIN. After F2 (no fallback, explicit auth re-check) there is no remaining single-POST broadcast path; a per-session PIN would further harden the stolen-session-cookie case but is UI-impacting and remains tracked, not blocking.
- Trust-model assumption unchanged: single user, local machine, loopback bind, optional password. Network exposure (`DASHBOARD_BIND` non-loopback) remains correctly gated by `secureBindHost()` requiring a password.
