# AIW Security Audit — Focused Review (2026-05-18)

Scope: the 10 areas in the review brief. Reviewer: Claude (defensive audit).
Verdict: **the codebase is heavily hardened.** Every primary attack class in
the brief is already mitigated by explicit, documented controls (the `AUD-###`
/ `F#` fix markers). No high/critical findings. Findings below are residual
hardening items (LOW / INFO).

---

## Per-area result

### 1. RPC injection via chat — MITIGATED
`/api/rpc/wallet`, `/api/rpc/daemon`, `/api/btc/rpc`, and the chat tool
pipeline (`handleChat`/`handleChatStream`) all run a **fail-closed allowlist**
(`isRpcMethodBlocked` / `isBtcRpcMethodBlocked`, server.mjs:1299/1356).
Key-export (`query_key`, `dumpprivkey`, `listdescriptors`, `export_*`) and
raw-broadcast (`relay_tx`, `submit_transfer`, `sendrawtransaction`) are
denylisted *and* absent from the allowlist; anything unknown (e.g.
`set_daemon`) is rejected. Spending methods are forced to `do_not_relay:true`
in chat and 403'd on the raw proxies (`RPC_PROXY_SPEND_MSG`). The duplicated
pipeline in both chat handlers is consistent. **No injection path found.**

### 2. Prompt injection → unauthorized transactions — MITIGATED
A poisoned memo / contact label / tool result / fetched body cannot move
funds: XMR/BTC spends are intercepted into `_pendingPreview` /
`_pendingBtcSend`, and swap/exchange/Strike spends into
`_pendingSpendAction`, all requiring an authenticated, human
`/api/.../confirm-chat` (+ Strike PIN) within a 5-min window
(server.mjs:1178-1233, 8611-8660). The staged object — not the agent's prose
— is what executes; the system prompt is never trusted to gatekeep. The Agent
SDK model is clamped to an allowlist so injection can't pin the priciest
model against the subscription.

### 3. .env / secret exposure — MITIGATED
`GET /api/settings` returns `••••••••` for every secret and only boolean
`has_*` flags for passwords/PINs (`maskSecret`, server.mjs:1470). Masked
placeholders are rejected on write-back. No endpoint returns
`DASHBOARD_PASSWORD`, `*_PIN`, or API keys in cleartext.

### 4. Dashboard auth bypass — MITIGATED
Session tokens are 32-byte CSPRNG, stored Map+TTL (server-side expiry),
`HttpOnly; SameSite=Strict`. Password compare is constant-time
(`safeStrEqual`). Brute force: 5/15-min lockout on the real TCP peer IP
(`getTrustedIP` — X-Forwarded-For is explicitly *not* trusted; the legacy
`getClientIP` is dead code, never called). Passwordless mode is fenced by
`secureBindHost()` (hard-fail on non-loopback bind w/o password) plus the
DNS-rebinding Host guard (server.mjs:7441-7458).

### 5. Path traversal — MITIGATED
`/api/skill/<name>` → `_safeSkillSeg` strips to `[A-Za-z0-9_-]`
(kit-prompt.mjs:565). `/favicon_io/` rejects `..` and `/`. Static serving
normalizes via `join` then checks the `PUBLIC_DIR` prefix. txid params are
`^[0-9a-f]{64}$`-validated.

### 6. XSS — MITIGATED
`chat.js renderMarkdown` escapes `& < >` **before** any markdown transform and
only ever emits a fixed safe tag set (no `<a href>`, no attribute
interpolation, no `javascript:`), so a prompt-injected agent reply can't
inject DOM. `dashboard.js` routes user/tx-derived fields (notes, labels,
payment IDs, addresses) through `escapeHtml` (100 call sites); grep found no
unescaped interpolation of `note/payment_id/label/memo` into `innerHTML`.

### 7. Queen's Decree kill switch — MITIGATED
`/api/decree/execute` requires `isAuthenticated` **and** the Decree PIN
(`checkDecreePin`, constant-time, 5/15-min lockout), refuses re-entry on
`executing`/`complete`, and logs. Decree-protected `.env` keys need the
current PIN even for an authed session; first beneficiary cannot be set
without co-establishing a PIN (AUD-#16). `/api/decree/test` is auth'd +
30s/IP rate-limited (anti-SSRF/flood). status/log are auth-gated.

### 8. CORS / CSRF — MITIGATED
Cross-origin `/api/*` with an `Origin` is 403'd unless same-origin (exact
host compare, AUD-#4) or on the mobile allowlist. `Access-Control-Allow-
Credentials` is only sent to allowlisted origins; `http://localhost` is
deliberately excluded. CSRF defense is the `SameSite=Strict` session cookie;
passwordless mode adds the DNS-rebind Host pin.

### 9. Agent SDK provider session leakage — MITIGATED
Subprocess env is an **allowlist** (`ENV_PASSTHROUGH`) plus a belt-and-braces
`^(ANTHROPIC|AWS|GOOGLE|GCP|AZURE|CLAUDE_CODE)_` strip — no `.env` secret
reaches the spawned CLI. Pinned to an empty scratch `cwd`,
`settingSources:[]`, `mcpServers:{}`, `allowedTools:[]`, `maxTurns:1`. The
subscription session can't be repointed or exfiltrated by a working-tree
`.mcp.json`/`.claude` file.

### 10. Wallet / seed file access — MITIGATED
No endpoint serves wallet/key files. `/api/export` returns only addresses +
tx history (no keys). `/api/wallets/seed` is POST-only, gated by
`checkSeedRevealGate` (auth + second factor: dashboard or wallet password +
3/15-min lockout + audit log). `query_key` is on the RPC blocklist so the
agent can't reach it.

---

## Residual findings (LOW / INFO — defense-in-depth)

| # | Sev | Finding | Status |
|---|-----|---------|--------|
| R1 | LOW | **Strike PIN compare is not constant-time.** `validateStrikePin` used `String(pin) !== String(STRIKE_PIN)`, unlike the login/Decree/seed paths which use `safeStrEqual`. Timing side-channel; bounded by the 3-attempt lockout. | **✅ FIXED** |
| R2 | LOW | **Zero-config seed reveal.** With *no* `DASHBOARD_PASSWORD` and *no* `WALLET_PASSWORD`, `evaluateSeedSecret` accepted `{confirm:true}` from any loopback caller to reveal the 25-word mnemonic. | **✅ FIXED** |
| R3 | INFO | **Static-file prefix check** used `fullPath.startsWith(PUBLIC_DIR)` — a sibling dir literally named `<PUBLIC_DIR>-x` would pass. Not exploitable today (no such sibling; `join` normalizes `..`). | **✅ FIXED** |
| R4 | INFO | **`/api/rpc/wallet` & `/api/rpc/daemon` lack an explicit `isAuthenticated` re-check** (rely on the global gate only), unlike `/api/btc/rpc`, transfer, decree, seed which add defense-in-depth. In passwordless-loopback mode any local process can read balances/addresses (privacy, not fund movement — spends are 403'd). | Open — optional parity hardening |
| R5 | INFO | **Origin-less CSRF surface.** The cross-origin block is `reqOrigin && !sameOrigin` — a request with no `Origin` header skips it. Real protection (SameSite=Strict cookie / DNS-rebind pin) holds, so not practically exploitable, but the asymmetry is worth a comment/guard. | Open — accepted (SameSite=Strict mitigates) |

---

## Fixes applied (2026-05-18)

All three actioned items are in [`ui/server.mjs`](../ui/server.mjs) and tagged
with `AUD-#R1` / `AUD-#R2` / `AUD-#R3` inline comments. `node --check` passes.

### R1 — constant-time Strike PIN compare
`validateStrikePin()` now compares with `safeStrEqual(pin, STRIKE_PIN)` (the
same `timingSafeEqual`-backed helper used by the login, Decree, and seed
gates) instead of `String(pin) !== String(STRIKE_PIN)`. `safeStrEqual` returns
`false` for a missing/empty PIN (length mismatch), so the prior `!pin` guard
is subsumed and behaviour is otherwise unchanged. Removes the
length/prefix-via-response-time side channel and brings the Strike PIN to
parity with every other secret check in the codebase.

### R2 — refuse seed reveal when no second factor exists
`evaluateSeedSecret()` no longer accepts an in-band `{confirm:true}` flag as a
stand-in for a real secret. When **neither** `DASHBOARD_PASSWORD` nor
`WALLET_PASSWORD` is set it returns `hasSecret:false`, and
`checkSeedRevealGate()` now hard-refuses with a clear setup instruction
("set a dashboard or wallet password, then try again"). This refusal is a
**configuration** error, not a guess, so it deliberately does **not** consume
a brute-force lockout attempt, and it is written to the `[wallet][AUDIT]`
log. Net effect: the 25-word mnemonic can never be exfiltrated by a
same-machine process / CSRF-staged fetch / DNS-rebind page in a zero-config
deployment — a genuine second factor must exist first.

> Behavioural note: any zero-config (no dashboard *and* no wallet password)
> install will now see the seed-reveal UI return a 403 with the setup
> instruction instead of revealing on `confirm`. This is intended; configuring
> either password restores the flow (now gated by that password).

### R3 — strict static-file path boundary
The directory-traversal guard changed from
`!fullPath.startsWith(PUBLIC_DIR)` to
`fullPath !== PUBLIC_DIR && !fullPath.startsWith(PUBLIC_DIR + sep)` (`sep`
imported from `node:path`). A sibling path whose name merely *begins with*
the public-dir name (`…/public-evil`) is no longer treated as inside
`PUBLIC_DIR`; only the dir itself or paths strictly beneath it (separator
required) pass. `join()` already normalised `..`, so no legitimate asset
request is affected.

## Recommendation
R1–R3 fixed. R4 (explicit auth re-check on the XMR raw RPC proxies) and R5
(Origin-less request handling) remain optional defense-in-depth — both are
already neutralised by existing controls (raw-proxy spend 403 + fail-closed
allowlist; SameSite=Strict cookie + DNS-rebind pin) and carry no
fund-movement risk. Ship.
