Audit performed at v1.0 (pre-diverge), covering the hosted/server build (
next dev/next build+/api/*) and the static APK export. Method: self-interrogation of the attack surface + live adversarial testing against the running app (curl + browser). Re-audited after the v1.9.1 app-lock bypass fix: reset gate, PIN throttling, fail-closed lock state, cookie flags (see the threat-model rows andbun run test:security).
| Actor | Vector | Verdict |
|---|---|---|
| Malicious chat export (zip/txt) | Import path — hostile filenames, zip bombs, XSS in message text, scriptable media (SVG/HTML) | Hardened |
| Malicious web page | CSRF / cross-origin calls against a hosted instance | Mitigated (proxy origin check + SameSite cookie) |
| Malicious URLs inside chats | Server-side link-preview scraper → SSRF | Hardened |
| Network observer | Cookie theft / sniffing | Hardened (HttpOnly, SameSite, Secure on HTTPS transports) |
| Curious visitor | Open instance with no lock → reads/deletes chats | Documented risk — enable the PIN lock (below) |
| Network attacker (lock enabled) | POST /api/auth/lock {"action":"reset"} to disable the lock, then read the archive |
Hardened — reset now needs the current PIN, direct local access, or the operator's WA_LOCK_RESET_TOKEN |
| PIN guesser | Brute-force the 4–8 digit lock PIN over the network | Throttled — 5 wrong PINs / 15 min per client → 15-minute lockout (429) |
| DB outage mid-request | Lock gate fails open during a transient error | Fail closed — unknown lock state denies with 503 |
| Local device thief | APK data at rest | OPFS/SQLite under app sandbox + optional PIN screen |
-
Can a zip export write files outside the media folder (zip-slip)? No — tested with
../,..\\, and absolute-path entries. Filenames are reduced to a sanitized basename, chat ids must match^[a-z0-9]{1,64}$/i, and the resolved path must stay insidedb/media/<chatId>(belt-and-bracespath.resolve+ prefix check). Attack entries landed inside the chat folder under sanitized names; nothing escaped. -
Can a zip bomb exhaust memory? No — tested with a 300 MB-declared entry (306 KB compressed): the decompressed size is read from archive metadata before inflation and rejected against per-entry (80 MB) and cumulative (600 MB) budgets. Import finished in 0.03 s. Oversized transcripts (25 MB txt) return HTTP 413.
-
Can message text execute script (stored XSS)? No — all message bodies render through React text nodes (no
dangerouslySetInnerHTMLon user content anywhere). Payloads (<img onerror>,<script>,javascript:URLs, 200 KB single lines) imported and rendered inert; the HTML/Text exporters escape before linkifying, and onlyhttp(s)URLs become<a href>. -
What about scriptable media served back to the browser (SVG/HTML/GIF/PDF)? Those MIME types are served with
Content-Security-Policy: sandboxandX-Content-Type-Options: nosniff— verified live on a hostile SVG (onloadexfil script): script cannot run even when the file is opened directly on the origin. -
Can
/api/media/...read arbitrary server files (path traversal)? No —..%2f,%5c, and over-long encodings all return 404 (tested), and the DB file itself is unreachable through the media route. -
Can the link-preview scraper be used for SSRF? Blocked for loopback/private/link-local/metadata targets, including decimal/hex/octal integer hosts, IPv6 loopback,
*.local/.internal, and every redirect hop (max 5, re-validated). Tested:127.0.0.1,169.254.169.254,0x7f000001,2130706433,[::1]→ all refused;https://example.comworks. Response bodies are capped at 1 MB with a 4 s timeout; images resolved fromog:imageare now restricted to http(s). -
Is there authentication? What happens if I host this publicly? The app is a single-user local tool by design. Every data route is guarded by
assertAppUnlocked— a no-op until you enable the PIN lock. If you host it, enable Settings → Privacy → App lock first (scrypt-hashed PIN, HMAC-signed HttpOnlySameSite=Laxunlock cookie —Securewhen the request arrives over HTTPS — 12 h TTL).Lock hardening (audited after the v1.9.1 reset-bypass fix):
- Reset is owner-only.
POST /api/auth/lock {"action":"reset"}used to wipe the lock without any credential — a network client could disable the lock and read the archive. Reset now succeeds only with (a) the current PIN, (b) a provably local connection — loopback socket AND everyX-Forwarded-Forhop loopback (the owner at the server console / SSH tunnel, including through honest appending proxies), or (c) the operator-setWA_LOCK_RESET_TOKENheader. A remote caller with none of those gets 403 and the lock stays armed. - Loopback cannot be spoofed. Locality is decided from the TCP socket
address captured at the Node http layer (
src/instrumentation.ts), not fromX-Forwarded-For; a client-sentx-wa-socket-remote-addrheader is ignored unless the hook provably overwrote it. Local recovery additionally requires everyX-Forwarded-Forhop to be loopback — an honest appending proxy always appends the real client address, so a remote client can never forge an all-loopback chain (fake loopback entries it prepends are exposed by the appended tail). - PIN guessing is throttled — 5 wrong PINs within 15 minutes lock
that client out for 15 minutes (
429,Retry-After, correct PIN included, shared across verify/change/disable/reset). - The gate fails closed. If the lock state cannot be read (DB error), data routes deny with 503 instead of silently treating the app as unlocked.
- Forgotten-PIN recovery for the machine owner: see Forgotten PIN recovery.
Full flow tested live: enable → 401 everywhere → wrong PIN 401 (then 429 after 5 tries) → unauthenticated reset 403 → correct PIN unlocks → media/cookies verified → disable restores access. Automated regression tests:
bun run test:security(src/lib/lock-security.test.ts). - Reset is owner-only.
-
Can another website make calls into my running instance (CSRF)? Mutating (
non-GET)/api/*requests carrying a mismatchedOriginheader are now rejected bysrc/middleware.ts(403). Non-browser clients (curl, server-to-server) send no Origin and are unaffected. The unlock cookie is additionallySameSite=Lax, so cross-site form posts cannot ride an authenticated session. -
Are server-side secrets exposed to the client? No — the client bundle was scanned (
AIza…/sk-…/provider key names: zero hits). Built-in AI runs server-side via the SDK; the BYO Gemini key path passes through anx-gemini-keyrequest header and is never persisted. On the device transport the key lives only in the user's own browser/APK storage (by design, it is their key). -
Can the AI/ASR endpoints be abused for cost/DoS? Can the PIN be brute-forced? AI/ASR are rate-limited:
/api/ai20 req/min,/api/transcribe10 req/min per client, with lazy bucket pruning (shared helpersrc/lib/rate-limit.ts). Raw provider errors are never returned to clients; ffmpeg runs throughexecFile(no shell) with timeouts. The app-lock PIN uses a separate, stricter limiter (src/lib/pin-throttle.ts): 5 failed attempts per client within 15 minutes trigger a 15-minute lockout, keys are derived from the unspoofable socket address (or the trusted rightmostX-Forwarded-Forentry whenWA_TRUST_PROXY=1), and a wrong-PIN response always pays a 250 ms delay on top of the scrypt cost. -
SQL injection? Prisma parameterizes everything; the one raw query (
/api/memories) uses a tagged template with bound parameters — safe. -
Command injection? All child processes (
ffmpegposter generation, audio transcode) useexecFilewith fixed argument arrays and timeouts — no shell interpolation of user input.
next.config.ts— baseline security headers on every response:X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy: camera=(), geolocation=(), microphone=(self).src/middleware.ts— cross-origin mutating API calls rejected (CSRF).src/app/api/ai/route.ts— 20 req/min rate limit (new shared helpersrc/lib/rate-limit.ts;/api/transcriberefactored onto it).src/app/api/link-preview/route.ts—og:imageresults restricted to http(s) schemes.src/app/api/wallpaper/[name]/route.ts— addednosniff.- Removed the leftover unguarded
/apihello-world scaffold route.
The lock never permanently locks the owner out, but a PIN-less reset is now owner-only. On a server install, if you forget the PIN, pick one of these — all require access only the machine's operator has:
- At the server console / SSH (the request provably originates on
the machine — loopback socket, loopback-only forwarded chain):
A loopback socket with a loopback-only forwarded chain is honored as local recovery.
curl -X POST http://localhost:3000/api/auth/lock \ -H 'Content-Type: application/json' -d '{"action":"reset"}' - Recovery token (works from any client): set a one-time token in the
server environment, use it once, then remove it:
export WA_LOCK_RESET_TOKEN="$(openssl rand -hex 32)" # then restart the app curl -X POST https://your-host/api/auth/lock \ -H 'Content-Type: application/json' \ -H "x-wa-recovery: $WA_LOCK_RESET_TOKEN" \ -d '{"action":"reset"}' unset WA_LOCK_RESET_TOKEN # and restart again
- Directly in the database (no running-app cooperation needed):
sqlite3 db/custom.db "DELETE FROM AppSetting WHERE key IN ('lock:enabled','lock:pin:hash','lock:pin:salt')"
The device transport (APK / offline mode) is unaffected: its lock lives entirely in the browser/WebView, so the in-app "Forgot PIN?" reset keeps working without a PIN there.
- No-auth mode is still intentional for local use; always enable the PIN
lock before exposing the app beyond localhost. (A formal
APP_PINenv gate for headless deployments is a beta candidate.) - Setting a lock is not itself authenticated. On an unlocked, hosted instance a network client can still enable a lock with its own PIN (annoyance/DoS, no confidentiality impact — the same client could read everything anyway while no lock is set). The proxy origin check blocks browser-based cross-site attempts.
- In-memory rate limits (AI/ASR and the PIN lockout) reset on restart and are per-process — fine for a single-user tool; use a real limiter only if multi-tenant ever happens.
- Local-recovery caveat: loopback reset trust requires the whole
forwarded chain to be loopback. If you run a reverse proxy on the same
machine that strips
X-Forwarded-For(or hardcodes it to a loopback value), proxied remote requests become indistinguishable from console access — use theWA_LOCK_RESET_TOKENpath in such setups instead. Behind a proxy that does setX-Forwarded-For, also setWA_TRUST_PROXY=1so PIN throttling keys on the real client rather than the proxy's address (proxied clients otherwise share the proxy's throttle bucket). - No strict CSP on HTML documents (Next.js inline bootstrap scripts make a nonce-based CSP a non-trivial change; candidate for beta).
- Dependency scanning (CVE watch) is manual — consider
bun auditin CI. - The APK export ships no server; its attack surface is the WebView + OPFS data, sandboxed by the Android app sandbox.
| Test | Result |
|---|---|
zip-slip (../, ..\\, absolute, media-referenced) |
contained ✅ |
| zip bomb (300 MB declared / 306 KB real) | rejected pre-inflation ✅ |
| 25 MB transcript inside zip | HTTP 413 ✅ |
| media path traversal (3 encodings) | 404 ✅ |
| hostile SVG stored XSS | CSP: sandbox + nosniff ✅ |
| XSS payloads in messages (browser) | inert (React text) ✅ |
| SSRF (6 probe forms) | refused ✅ |
| PIN lock full flow | 401/200 transitions correct ✅ |
| unauthenticated lock reset (remote) | 403, lock stays armed ✅ (test:security) |
| lock reset with current PIN / loopback / token | 200 ✅ (test:security) |
| PIN brute-force (5 wrong → lockout) | 429 + Retry-After ✅ (test:security) |
| lock gate during DB error | 503 fail-closed ✅ (test:security) |
| spoofed socket header / XFF-present loopback | ignored / rejected ✅ (test:security) |
| unlock-cookie flags (HttpOnly, SameSite, Secure@https) | verified ✅ (test:security) |
| client bundle secret scan | clean ✅ |
| security headers on responses | present ✅ |