Security audit: 0.3.0-dev.2¶
A review of the whole app (server and client) done against a running production build, not just by reading code. Every finding below was either reproduced with a proof-of-concept request against a local server (before the fix) or checked with one (after the fix); each fix has a regression test in src/server/*.test.ts (most in hardening.test.ts). Scope: the main branch at 0.3.0-dev.1, plus the Welcome setup added in 0.3.0-dev.2.
Result: no way to read or change another household's data, no SQL injection, no stored XSS, and no sign-in bypass were found. One availability bug (a hanging request), several brute-force and takeover weaknesses, and a number of smaller hardening gaps were found and fixed. Items that remain are listed as accepted, with the reason.
How it was tested¶
- A harness started the built server (
.output/server/index.mjs,NODE_ENV=production) on a fresh data directory, created two households with an owner, two members and a second owner, planted a surprise gift, a wishlist and a Secret Santa draw, and then attacked it over HTTP as an anonymous visitor, a member, the recipient of a surprise, and another household's owner. - About 28 write and read routes were replayed across households (IDOR); the surprise-safe views (gifts, ledger, export, CSV, calendar, budgets, wishlist) were diffed for the recipient; sign-in, setup and passkey endpoints were hammered with concurrent and forged-header requests; malformed paths, oversized and chunked bodies, hostile backups, hostile CSV and hostile names were sent in.
- Dependencies (
npm audit), the git history (secret patterns, committed key or database files), the built assets (source maps), the Dockerfile, compose files and workflows were reviewed. - After the fixes the same scripts were re-run: every item below marked fixed reports clean.
Findings¶
Severity is the impact if exploited on a typical home-hosted install. "Verified" says how the issue was shown and how the fix is checked.
| ID | Severity | Finding | Status | Verified |
|---|---|---|---|---|
| S1 | High | First-owner takeover. Until the first owner exists, /api/setup is open to anyone who can reach the server, and a server exposed to the internet before setup could be claimed by a stranger (the only brake was the optional SETUP_CODE, which also had no guess limit and a non-constant-time compare). |
Fixed. Without SETUP_CODE the first owner can only be created from a private/home-network address (ALLOW_PUBLIC_SETUP=true opts out); setup attempts are rate limited (10 per 15 min per address); the code is compared in constant time; the setup page explains the refusal; a start-up warning is logged while no owner exists. |
PoC: 15 concurrent setups gave exactly one owner (the race itself is fine). Tests: public address refused, code accepted, guesses throttled (hardening.test.ts). |
| S2 | High | A request path with broken percent-encoding hangs forever (GET /%E0%A4%A, also under /api/ and /assets/). The framework throws before any middleware runs and never answers, so a handful of requests can tie up connections. |
Fixed with a Nitro plugin that wraps the app's fetch and answers 400 first. |
PoC against the built server: request timed out before; 400 now for four malformed paths. Test: pathIsWellFormed. |
| S3 | Medium | Sign-in lockout was bypassable and abusable. Misses were counted per account and per address, so (a) anyone on the home network, or anything behind a trusted proxy, could rotate a forged X-Forwarded-For to get unlimited guesses at one account, and (b) five wrong tries from anywhere locked the real owner out. |
Fixed. Counters are now per account+address (5), per account from anywhere (20) and per address across accounts (30), kept in a pruned, size-capped map. A good sign-in forgives only that address's own misses. | PoC: 40 guesses with 40 forged addresses got 0×429 before. Tests in hardening.test.ts (rotation, spraying, proxy address, Retry-After). |
| S4 | Medium | Passkey sign-in shared one global lockout key, so 20 failures from any address blocked passkey sign-in for everyone (denial of service). | Fixed. Per-address counters (20 misses) replace the shared bucket; the options endpoint is rate limited (60 per 15 min per address) and the open-challenge table is capped at 500 rows. | PoC: after failures from one address another address got 429. Test: lockout isolation. |
| S5 | Medium | Secret Santa organiser could switch peeking on after the draw and read every pairing, with no sign to participants. | Fixed. Peeking can only be enabled before the draw (409 afterwards). | PoC reproduced the leak; test in hardening.test.ts and the updated secretsanta.test.ts. |
| S6 | Medium | Restoring a database with no owner login locks everyone out and re-opens first-run setup to the next visitor. | Fixed. A restore file must contain an owner; setup is also guarded by S1. Uploaded files are opened with trusted_schema=OFF, only table data is copied (views and triggers in the file are never imported), and a safety backup is taken first. |
Test: no-owner file refused (400), hostile view/trigger not imported. PoC re-run: restore 400, setup still closed. |
| S7 | Medium | Another owner could demote or remove the instance admin (the first owner, who alone manages backups and other households), so a second owner could take over those powers. | Fixed. That login can't be demoted or removed by anyone. | Test: both refused (409), backups stay admin-only. |
| S8 | Medium | Password hashing was at the low end (scrypt N=2^15, p=1), and a restored or edited row could carry absurd scrypt costs. | Fixed. New hashes use N=2^16, r=8, p=2; old hashes are upgraded after a successful sign-in; stored parameters are bounds-checked; password-less (SSO-only) logins burn a hash so timing matches. | Tests: parameters, rehash on login, tampered cost refused fast. Timing PoC: unknown, wrong-password and SSO-only logins within ±8 ms. |
| S9 | Medium | Chunked or unbounded request bodies were buffered before the size check (request.text()), so a large upload could exhaust memory. |
Fixed. Bodies are counted while they are read and cut off at 5 MB (256 MB for restore, admin only). | PoC: a 150 MB body is refused with 413 in under a second with no memory growth; test with a 12 MB chunked stream. |
| S10 | Low | Wishlist URLs imported from CSV skipped the http(s) check used by the form, so junk such as https://javascript:alert(1).x was stored. (React would not run it, but it is not a link.) |
Fixed server-side with the same safeUrl, plus a client check before rendering a link. |
PoC reproduced; CSV test. |
| S11 | Low | iCalendar injection: a bare CR or control character in a person's name could start a new property line in the calendar feed (for example an ATTENDEE). |
Fixed. Control characters are stripped and newlines escaped. | PoC showed the raw CR; test checks no injected line. |
| S12 | Low | Passkeys did not require user verification, so a stolen unlocked device (or a key with no PIN) was a complete sign-in. | Fixed. Registration and sign-in require user verification. | Passkey tests pass with verification required. |
| S13 | Low | Deleting your own person revealed whether a surprise was planned for you ("This person is on a gift"). | Fixed. Removing yourself always answers the same way. | Test compares the answer with and without a surprise. |
| S14 | Low | SSO provider endpoints were not checked, and redirects were followed: a hijacked discovery document could point the server at an internal address (SSRF) or a plain-HTTP endpoint. /api/auth/oidc/start wrote a database row per hit with no limit. |
Fixed. Every endpoint must pass the same https rule as the issuer (private-network http only when allowed), redirects are refused, and start is limited to 30 per 15 min per address. | Tests with a fake provider naming a public HTTP token endpoint, and a 31-request burst. |
| S15 | Low | Ask feature: no per-person limit (a family member could burn the xAI key), no Origin or Welcome-setup check on the server function, and the ledger was pasted into the prompt without delimiting, so a note such as "ignore your rules" was taken as an instruction. | Fixed. 30 questions per hour per login, Origin and Welcome checks, ledger passed as <ledger> untrusted data with rules not to follow instructions in it or emit links. The model has no tools, so injection can only change the wording of an answer the same person sees. The API key never leaves the server. |
Code review plus the build; behaviour of the model itself is not testable offline. |
| S16 | Low | Passkey delete with a malformed id returned 500 (an uncaught URIError). |
Fixed. 400. | Test. |
| S17 | Low | Backups were world-readable under the container's default umask. | Fixed. Backup folder 0700, files 0600. |
Test checks the modes. |
| S18 | Low | Missing Cross-Origin-Resource-Policy, and the HTML shell had no Cache-Control. |
Fixed. same-origin on everything, no-cache on HTML. |
Headers diffed against the running server. |
| S19 | Low | CI/CD hygiene: CI had default token permissions, release gave contents: write to every job, actions were pinned to moving tags, checkout kept the token, no dependency update automation. |
Fixed. Read-only default, job-level write, SHA-pinned actions, persist-credentials: false, Dependabot for Actions, npm and Docker. |
Workflow diff; CI run on the pushed commit. |
| S20 | Low | Container: all Linux capabilities were kept by default in compose. | Fixed. cap_drop: ALL and no-new-privileges. The image already runs as the unprivileged node user with only /data writable. |
Compose validated in CI; not run under Docker on the test box (see Not tested). |
| S21 | Low | Dependencies: npm audit reported known issues in vite, zod and fast-uri. |
Fixed. Updated; 0 known vulnerabilities. | npm audit. |
| S22 | Info | .env.example was referenced by the README but git-ignored, so it was never published. |
Fixed. Tracked and updated. | git ls-files. |
Checked and found sound¶
- Authorization / IDOR: every route was replayed from another household, from a member, and (for surprises) from the recipient: gifts, people, wishlist, budgets, occasions, users, exchanges and participants, sharing links, backups, households, CSV preview/import/export,
/api/export,/api/ledger,/api/calendar. Responses are404or403, and the data was unchanged afterwards. Lists and exports of one household never contained another's records. - Surprise safety: the recipient never sees the gift, its budget, its CSV row, its calendar entry, or the reserved/purchased mark on their own wishlist, and cannot edit or delete it.
- Secret Santa: each participant gets only their own draw; the organiser sees no pairings unless they chose to peek before the draw; non-organisers cannot change or redraw.
- Sessions and cookies: 256-bit random tokens, only a SHA-256 hash stored, 14-day sliding expiry,
HttpOnly,SameSite=Lax,Secureand the__Host-prefix over HTTPS, sessions revoked on password change, deleted with the user. - CSRF and Origin: every write needs the per-session token and a matching Origin;
text/plainand cross-site form posts are refused; there is no CORS. - Proxy trust: a client-sent
X-Ribbon-Peeris discarded;X-Forwarded-*is believed only from trusted peers. - User enumeration: same message and timing for unknown, wrong-password and SSO-only accounts.
- SQL injection: every query is parameterised; the few dynamic identifiers (table names in restore) come from the database's own schema, not from input.
- XSS: React escapes all output; the only
dangerouslySetInnerHTMLis a static theme script. No markdown or HTML is rendered from user text. Wishlist URLs are http(s) only. - CSV: exports prefix
= + - @cells so spreadsheets don't run them as formulas; imports are capped at the row limit and validated. - ICS feed token: 192 bits, regenerating kills the old link, no token gives 404.
- Backup download and restore: names must match a strict pattern, so
../, encoded separators and NUL bytes all fail; non-SQLite and newer-schema files are refused. - OIDC: state, nonce and PKCE (S256) with single-use, expiring flows tied to a cookie; the callback address comes from
PUBLIC_URLor the trusted request host and must be registered at the provider, and there is nonext=style redirect parameter; accounts are only linked by e-mail when the provider says it is verified, the address is unique, andOIDC_AUTO_LINK_BY_EMAILis on. - WebAuthn: the relying-party ID and origin come from
PUBLIC_URLor the trusted request host; challenges are single use and expire. - Service worker:
/api/*is never intercepted, navigations are network-only, and only hashed assets and icons are cached, so signed-in data is never stored. - Errors and logs: the API returns generic 500s, stack traces go only to the server log, and no log line contains a password, token or the SETUP_CODE. Static probes (
/.env,/package.json,../paths) return the app shell with 404. - Docker image: non-root
nodeuser,/datais the only writable location, health check uses/api/health,.dockerignoreexcludes.env,.gitand data. - Git history: 39 commits scanned for API keys, tokens, private keys, key/database/
.envfiles: nothing found. Test fixtures contain throwaway passwords only. - Source maps: none are shipped in the production assets.
Accepted risks and limitations¶
| ID | Severity | What | Why it is accepted |
|---|---|---|---|
| A1 | Low | CSP allows inline scripts (script-src 'self' 'unsafe-inline'). |
The framework's server rendering needs inline bootstrap scripts, and nonces are not supported by this build. With no HTML injection point found (see XSS above), the practical risk is low. Revisit when the framework supports nonces. |
| A2 | Low | Default trusted proxies are all private ranges, so another machine on your LAN can forge X-Forwarded-For (it can no longer use that to dodge sign-in lockout, but it can make itself look public or private). |
Needed for the common home setup with a proxy on another LAN host. Set TRUSTED_PROXIES to just your proxy to tighten it. |
| A3 | Low | Household members are trusted editors: a member can replace or delete the household's whole ledger through the import or the normal edit calls. | This is the product's model (a family shares one ledger); owners control logins, sharing and backups. |
| A4 | Low | The instance admin can read everything in backups, including surprises planned for them. | Backups are a full copy of the database; only the person who runs Ribbon can open them. |
| A5 | Low | DNS rebinding could let a web page reach a Ribbon on the home network. | Such a page has no cookie or CSRF token, so it can read nothing private; the Host-based Origin check still holds. |
| A6 | Low | Sign-in lockout is in memory and resets on restart; the 20-per-account limit means a determined stranger can keep the real owner locked out for 15-minute stretches. | Chosen over letting guesses through; the owner can use a passkey or SSO, which have separate counters. |
| A7 | Info | A few existence oracles remain (for example "that login is gone" for ids in your own household). | They only reveal what a signed-in member of that household can already see. |
| A8 | Info | A LAN IP address appears in old test fixtures in git history. | Private address, no secret. |
| A9 | Info | The base image is pinned by tag, not digest, and the container's root filesystem is writable. | Dependabot tracks the tag; read_only: true with a tmpfs for /tmp is a good extra step but was not tested here. |
Not tested¶
- The Docker image and
docker-compose.ymlhardening options were not run (no Docker on the test box); CI builds and starts the image. - Behaviour against a real OIDC provider, real WebAuthn authenticators and a real xAI key (fake provider and software authenticators were used).
- Load and long-running behaviour; TLS termination (done by your reverse proxy).
- Browsers other than headless Chrome.
Reporting¶
See SECURITY.md.