A self-hosted application holding one household's financial history, reachable on a private origin, used by a small number of trusted people.
In scope: anything reachable over HTTP by an unauthenticated party or by a member acting outside their own data; anything that leaves the machine.
Out of scope: an attacker with the database file, the host, or root. The database is not encrypted at rest; disk encryption is the operating system's responsibility. Privacy between members separates people who trust each other — it is not an adversarial boundary.
Google OAuth 2.0 or any OpenID Connect provider, both with PKCE — or a password, described under Passwords.
- The
statevalue is 16 random bytes, held in memory, single-use, expiring after ten minutes. It is also bound to the browser that started sign-in by a short-lived cookie, so a callback link opened in somebody else's browser cannot sign them into the sender's account. - A code the provider rejects — forged, replayed or expired — or an ID token
that fails its checks is a 400 carrying the provider's reason, recorded in
auth_attemptsasrejected-code, not a server fault. - The email must already be a member with
allowed = 1. Others are refused and the attempt recorded inauth_attempts. - The provider must say the address is verified (
email_verified: truein the ID token), for Google and for any OpenID Connect provider alike. An unverified address — or a provider that does not send the claim — is refused with 403 before the allow-list is consulted, and recorded asunverified-email. Otherwise a provider that lets people register themselves would let anyone sign in as a member by typing their address. - Sign-in attempts are rate limited per source address; exceeding the limit
returns 429. Behind a proxy (
TRUST_PROXY) the source address is the right-mostX-Forwarded-Forentry — the one the proxy appended. Entries to its left are written by the client and would let it choose a fresh address per attempt. This assumes exactly one trusted hop (Fly's proxy, or one tunnel); with two, every request would appear to come from the first.
Sessions. 32 random bytes, base64url. Stored as a SHA-256 hash; the
plaintext exists only in the cookie. Cookie flags: HttpOnly, SameSite=Lax,
Path=/, and Secure when BASE_URL is HTTPS. A session lasts SESSION_DAYS
from sign-in, however often it is used — an absolute lifetime, not an idle
timeout, so a stolen cookie that is kept busy still dies on schedule. Signing
in again starts a new one.
Sessions can be revoked individually from Settings — your own only. Another member's session id answers exactly like one that does not exist.
API tokens. Authorization: Bearer. Stored as a hash, compared in constant
time, checked for revocation and expiry after lookup. Scoped read or
read-write, with a deny-list of path prefixes a token may never reach —
including token management itself. Rate limited per token. A token belonging to
a member who has been removed from the allow-list stops working, as their
sessions do, and a token cannot be minted while viewing as another member.
Development login (DEV_LOGIN) is absent from the production image: the
module is compiled then deleted, and the build asserts its absence. The
application also refuses to start with it enabled in a production-shaped
environment: NODE_ENV=production, a BASE_URL that is not loopback, .local,
.test or a private LAN address, Google or OIDC credentials, or
LOCAL_LOGIN.
Demo mode (DEMO_MODE) bypasses authentication. It refuses to start
alongside DEV_LOGIN, or alongside any real way in — Google or OIDC
credentials, or LOCAL_LOGIN — and refuses a database with a connected
mailbox, a saved statement identity, a member's password or a linked Google
account. A demo is production-shaped by design, so these, not NODE_ENV or the
hostname, are what tell a household's instance apart from one.
Per-member visibility is described in privacy.md: visible-entity
guards on every parameterised route, viewerMemberId through every query that
can return data for a person, and 404 rather than 403.
Two independent controls:
- The session cookie is
SameSite=Lax, and noGEThandler in the router mutates state — verified by a test that walks every handler for write calls. - Every unsafe method must carry an
Originmatching the deployment, or aRefererfrom it whenOriginis absent.Referrer-Policy: same-originguarantees a same-origin request carries aRefererand a cross-origin one does not. Failure returns 403.
Bearer-authenticated requests are exempt from (2) because they carry no cookie —
and only when they carry no session cookie: a request with one is held to (2)
whatever Authorization header it also sends.
The check is one middleware, applied before routing, and therefore covers every route.
Any redirect target taken from a request passes through safePath, which
requires a path beginning with a single /, rejects // and /\ (which
browsers resolve as another origin), and rejects control characters. Applied to
the sign-in return, the theme toggle, the review queue and the impersonation
banner.
src/http/html.ts escapes & < > " ' on every interpolation, covering both
element and attribute contexts. raw() is the only way to emit unescaped
output, and when() accepts only SafeHtml. Server-generated SVG escapes every
interpolated label.
jsonScript() escapes < as < for values embedded in script blocks.
- SQL: every query is parameterised. The single dynamically-assembled
UPDATEbuilds itsSETclause from literal column names. - Uploads: MIME allow-list of
image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf, checked against both the declared type and the extension. 10 MB limit. Stored as blobs, so no path is constructed from user input. Served withnosniff; PDFs are servedContent-Disposition: attachment, images inline. The filename in the header is reduced to word characters, dots, spaces, parentheses and hyphens. - Untrusted parsers: the PDF and CSV readers are fuzzed with empty, truncated, malformed, all-null, absurd-length and self-referential inputs. A PDF may decompress to at most 32 MB in total, however its streams are nested or repeated, so a small hostile file cannot hold the server.
- CSV exports: imported text (payees, narrations) is chosen by whoever sent
the statement or alert, so every exported cell that starts with
=,+,-,@, tab or carriage return is prefixed with'and opens as text, never as a formula. Plain numbers are left alone so amounts still sum.
Applied to every response:
Content-Security-Policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self';
connect-src 'self'; frame-src 'none'; frame-ancestors 'none';
form-action 'self'; base-uri 'self'; object-src 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: same-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), interest-cohort=()
Cache-Control: no-store
No third-party script, style, font or image is loaded.
statement_identityandgmail_connectionsare listed inNEVER_EXPORTEDand excluded from the JSON and CSV exports.- Statement passwords are used and discarded; only a description of which candidate worked is retained.
- The request log records method, path without query string, status and duration. No body, no headers, no query parameters, no financial values.
Every outbound host is fixed in source: Google's OAuth and Gmail endpoints, and
two price providers. User-supplied values reach only path segments and query
values, through encodeURIComponent. BACKUP_WEBHOOK_URL and HEARTBEAT_URL
are operator-configured.
None at runtime.
scrypt from node:crypto — N=32768, r=8, p=1 — with the parameters stored
inside the encoded hash, so the cost can be raised later without invalidating
existing passwords. An old hash keeps verifying and is rewritten on the next
successful sign-in.
- Stored in
member_passwords, never on the member row. That row is read on nearly every request; a secret that is never loaded cannot be logged, serialised into a view model, or exported by accident. - In
NEVER_EXPORTED. A hash is not a password but it is an offline guessing target, and an export is the most portable thing this app produces. - Verification is constant-time, and a malformed or hostile stored row fails rather than throwing — this is the sign-in path, and an exception there is a 500 on a login page. Parameters read from the database are bounded before use, so a tampered row cannot ask scrypt for unbounded memory.
- Eight failures lock the credential for fifteen minutes. Per credential, not per address: IP rate limiting already exists and is the right tool against a flood from one place, and the wrong one against somebody patient with many.
- A wrong password and an unknown address are refused in identical words. The
difference would disclose who is in this household. For the same reason an
address with no usable password (not a member, removed, or no password set)
locks after the same eight guesses, replayed from
auth_attempts, and spends a scrypt verification on a decoy hash so it takes as long to refuse. - Changing a password requires the current one, and signs out every other session the member has; the device that made the change stays signed in.
Three kinds, and the difference is not cosmetic.
| Thrown | Answers | Recorded as a fault? |
|---|---|---|
Refusal (src/core/refusal.ts) |
422, with its message | No |
Missing (extends Refusal) |
404, with its message | No |
HttpError (src/http/router.ts) |
its own status | No |
| anything else | 500, "Something went wrong" | Yes |
The last row is why this matters beyond politeness. An unexpected throw is recorded as a genuine defect, and a mistyped id in a URL once did exactly that: the failed check reached the health page, the health check reported the instance unhealthy, the platform stopped routing to it, and the public demo was down for twenty-four hours. Somebody guessing a URL must not be able to do that.
Missing exists so the domain can say "that is not here" without reaching for
the HTTP layer, which it must not depend on. It extends Refusal so both places
that already treat a refusal as deliberate — main.ts and the test harness —
needed no change at all.
src/web/no-crash.test.ts holds the line: every POST route, given four shapes of
bad body, and every id-addressed route given an id that names nothing. A 500
from any of them fails the suite.
src/pdf/decrypt.ts calls MD5 and SHA-256, and code scanning reports all three
as "password hash with insufficient computational effort". They are not password
hashes. Nothing in that file stores or verifies a credential: they are the
key-derivation steps ISO 32000 specifies for opening a PDF somebody else
encrypted, so the algorithm is a property of the file rather than a choice. MD5
replaced with scrypt there is not a hardened decryptor, it is one that cannot
open the statement. The SHA-256 in Algorithm 2.B is additionally only the seed
of a loop that runs at least sixty-four further rounds over sixty-four
repetitions of the password; the scanner sees the first line of it.
Each site carries a codeql[js/insufficient-password-hash] comment and the
reason. The alerts are dismissed as false positives rather than left open,
because a security tab with three permanent known-good entries is a security tab
nobody reads.
Two more alerts flag regex .replace() calls shaped like an HTML or XML
sanitizer — "bad HTML filtering regexp" and "incomplete multi-character
sanitization" — in test files, not in anything that touches a browser.
src/web/iso-dates.test.ts's visibleText() strips tags from a test's own
fetch of this app's server-rendered response, so it can search what a person
reads for a raw ISO date (WEBUX-1). The input is fixed templates this suite
controls, not markup an attacker supplies for display; a malformed match
confuses an assertion, at most, not a browser.
src/web/sitemap.test.ts strips XML comments from website/sitemap.xml
before checking its <loc> elements. That file is committed to this
repository, never attacker-supplied, and XML comments cannot nest — -- is
forbidden inside comment content by the spec — so there is no valid document
where the overlap the query warns about (a delimiter pair reassembling from
the pieces either side of a removed match) can arise.
Neither result feeds a decision this app makes, let alone one about
untrusted content, so neither is the thing these queries exist to catch. Each
site carries its codeql[...] comment and the reason, for the same reason as
above.