Sources: security review (0 Critical / 1 High / 5 Medium / 6 Low) and code review (1 Blocker / 6 Major / 11 Minor), 2026-08-31. Overlapping findings merged.
Order: severity high → low. Each item lists files and the concrete fix. Verification at the bottom. Check off items as they land.
- B1. Encrypted wallet → infinite reload loop; password modal never renders
app/src/pages/index.astro:44, 93, 242, 306–308needsPasswordis declared but never assigned server-side. After/api/wallet-openreturnsneeds_password, the client reloads, the server still renders the "Opening wallet…" screen, and the loop repeats forever.- Fix: in the balance-fetch catch block, detect the password-needed state
(probe via
ensureWalletOpen()/ inspectwallet_infoerror) and setneedsPassword = trueso the modal renders. - Add a regression test for the
needs_passwordrender path.
- H-1. Plugin install = session cookie → RCE, no step-up auth
app/src/pages/api/plugins/install.ts,app/src/lib/plugins.ts:109–188, 213–232,app/src/pages/plugins/[plugin]/[...path].astro:97- Any session holder can POST a
.tgzwhose code isimport()ed in-process with full FS/network/wallet-RPC access. Inconsistent with the app's own threat model (seed reveal / MCP spend require fresh TOTP). - Fix (incremental):
- Require a valid current TOTP code on install/uninstall — reuse the
pattern from
api/settings/reset-2fa.ts/api/settings/mcp-settings.ts. - Stop rendering plugin HTML raw via
set:html; escape it or render in a sandboxed iframe (sandboxwithoutallow-same-origin). - (Later — deferred, as planned) signed plugin packages: ed25519 signature in
plugin.jsonverified against a pinned key; separate origin for plugin serving.
- Require a valid current TOTP code on install/uninstall — reuse the
pattern from
- M1. Rate limiting keyed on spoofable
x-forwarded-for; unbounded mapsapp/src/pages/login.astro:41–44,app/src/pages/api/rpc.ts:7–8,app/src/lib/auth.ts:199, 224,app/src/lib/passkey.ts:49–69- Direct exposure (no proxy) means the header is client-controlled: rotates
past the 5-attempt login lockout;
loginAttempts/rpcAttemptsnever pruned (memory exhaustion). NotependingChallengesdoes prune. - Fix: prefer
Astro.clientAddress; trust XFF only when aTRUST_PROXY=trueenv is set. Prune expired entries on each check (mirrorcreateChallenge). Alignrpc.tsfallback withlogin.astro(clientAddress, not'unknown').
- M2.
tsc --noEmitfails (19 errors); CI never typechecksapp/src/lib/plugins.test.ts:215–294(readdirSyncmock typed asstring[]vsDirent[]),app/src/pages/api/plugins/[id]/toggle.test.ts:25,38,uninstall.test.ts:17(invalidas APIContextcasts).- Fix: type the mock as
Dirent[](mockReturnValue(... as unknown as Dirent[])), build test contexts via a typed helper, addastro check(afterastro sync) ortsc --noEmitto the CI test job.
- M3.
/api/block-streamverifies tokens against hardcoded version 0app/src/pages/api/block-stream.ts:18(also 22–24)- Pre-password-change tokens stay valid here after revocation (and valid v1 tokens are wrongly rejected, silently breaking the live feed).
- Fix (one line): pass
getPref<number>('auth.session_version') ?? 0toverifySessionToken. Also: lines 22–24 duplicate env/URL logic fromwallet-rpc.tswith a different fallback — move URL construction intowallet-rpc.tsexports and reuse.
- M4. Seed import deletes wallet file before recovery succeeds
app/src/pages/management/wallet.astro:122–128unlinkhappens beforerecoverWallet(which can run "many minutes"); a crash/timeout mid-scan destroys the wallet.- Fix: recover to a temp path first, then swap atomically:
recoverWallet(path + '.recover')→ close →unlink→rename→openWallet.
- M5.
rpcCallhas no fetch timeout; unguarded JSON parsingapp/src/lib/wallet-rpc.ts:71–78, 93- Hung daemon blocks API routes/SSR indefinitely; non-JSON 200 body throws a
raw
SyntaxErrorinstead ofWalletRpcError. Inconsistently,telegram.ts:82already usesAbortSignal.timeout. - Fix: add
signal: AbortSignal.timeout(30_000)(longer/opt-out forrecoverWallet); wrapres.json()in try/catch →WalletRpcError. Also add timeout tosendTelegramMessage/sendTelegramPhoto.
- M6. Password-change logic duplicated
app/src/pages/management/settings.astro:24–43vsapp/src/pages/api/settings/password.ts:16–41- Fix: extract
resolvePasswordChange({...})intosrc/lib/with unit tests (pattern:resolveMcpSettings); both paths call it.
- S-M1. No Content-Security-Policy
app/src/middleware.ts:20–25- Add CSP in
applySecurityHeaders, roll out report-only first:default-src 'self'; img-src 'self' https: data:; script-src 'self' 'nonce-…'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'. Needs nonces/hashes for Astro inline scripts and care for the plugin iframe.
- S-M2.
javascript:URI inhreffrom chain-controlled metadataapp/src/components/TokenManagePanel.tsx:354, 478info.metadata_uri.textrendered directly as<a href>; React does not sanitize the URL scheme.- Fix: shared helper — render as link only when
new URL(uri)parses withhttps:/ipfs:(mapipfs://→ gateway); otherwise plain text. Reuse the helper wherever chain-supplied URIs are rendered.
- S-M4. web-gui container runs as root over wallet data
app/Dockerfile(noUSER),docker-compose.yml- Fix: runner stage —
addgroup/adduser(uid/gid 10001),chown /app,USER app; keep mounted-dir ownership compatible withML_USER_ID.
- S-M5. GUI binds to all interfaces by default
docker-compose.yml,docker-compose.dev.yml,deploy/docker-compose.yml- Fix: default
127.0.0.1:${WEB_GUI_PORT:-4321}:4321;init.shasks "expose to network?" and opts in explicitly. (Also consider the unauthenticatedapi-web-server:3000indexer port.)
- L1.
/api/ipfs-uploadhas no upload size limit (app/src/pages/api/ipfs-upload.ts:35–38) — rejectfile.size > Nearly; enforceContent-Lengthceiling (50 MB cap already used by plugin install / setup — match it). - L2. Unbounded fan-out in
/api/address-tokens(:33–56) — capaddresses.length(≤ 200), batch token-info enrichment. - L3. PBKDF2 iterations below OWASP guidance (
app/src/lib/auth.ts:49,init.sh,tools/reset-password.sh) — 100k → ≥210k; keep format-parsing so old hashes verify, rehash-on-login. - L4. Seed phrase in POST-response HTML without
no-store(app/src/pages/setup.astro:62–65, 127–134,management/wallet.astro:79–88, 222–269) — setCache-Control: no-storeon responses containing seed material. - L5. Images pulled as
:latest+ auto-update (docker-compose.yml,deploy/linux.sh) — pin by digest (image@sha256:…), keep Watchtower opt-in. → LANDED asML_*_IMAGEenv overrides (digest-pinnable) + documented path; Watchtower remains profile-gated (opt-in). Actual digests must be resolved from the registry at release time. - Minor: FormData coercion (
settings.astro:46, 63, 78–80, 96, 103–104) — route every field through the existingstr()helper. - Minor: N+1 token-info RPC (
index.astro:62–64) — singlegetTokensInfo(tokenIds)call instead of per-tokenmap. - Minor: plugin
entrypath traversal (app/src/lib/plugins.ts:164–166, 221) — reject entries containing..or leading/. - Minor: txWatcher duplicate EventSources (
app/src/lib/txWatcher.ts:39–42) —es.close()before nulling on error (or don't null; rely on native reconnection). - Minor:
prefs-db.tsrobustness (:21, 25) — try/catch aroundJSON.parse; adddeletePrefinstead of writing"null"rows (setPref(key, null)used byuninstallPlugin). - Minor: stale session cookie after version bump (
app/src/middleware.ts:52–59) — comment or skip rolling refresh when the version changed mid-request. - Minor: dead route — remove
/api/loginfromPUBLIC_PATHS(app/src/middleware.ts:14). - Minor:
isValidRpIdall-hex hostname false positive (app/src/lib/passkey.ts:81) — require:or[before applying the IPv6 charset check. - Minor: MCP
send_coinsamount cap (app/scripts/mcp-server.mjs:249) — optional per-tx cap pref to limit blast radius of a hijacked AI client.
-
escHtmlescape'(addresses.astro:238–244). - SSE heartbeat for
/api/block-stream(low impact; EventSource reconnects). -
wallet.astro:76— coerceform.get('action')instead ofas string. -
astro.config.mjs:11— fix "ponytail" comment / remove stray TODO note. -
settings.astro:143–147— render stored secrets with atype="password"reveal pattern. (Inputs are alreadytype="password"; a JS reveal toggle remains optional.) -
npm audit fix— clear dev-onlynanoidhigh finding (viapostcss←autoprefixer), not shipped to production. - Docs: note that LAN plain-HTTP hostnames fail login due to
Securecookie (localhost exemption only) — availability footgun.
npm run buildandtsc --noEmit(orastro check) pass inapp/.- Full test suite green (448 tests; coverage thresholds 80% lines / 75%
branches) — add tests for: B1 render path, TOTP-gated plugin install,
rate-limit pruning/proxy handling, password-change shared helper,
recover-then-swap import,
rpcCalltimeout/JSON error, metadata-URI sanitizer. - Manual: open an encrypted wallet → password modal renders (B1); change password → old SSE token rejected / new works (M3); install plugin without TOTP → rejected (H-1).
- Manual:
docker compose up→ GUI reachable only on 127.0.0.1 (S-M5); container process runs as non-root (docker compose exec web-gui id) (S-M4).