Skip to content

Security: flaxvin/Pathayam

docs/security.md

Security

Threat model

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.

Authentication

Google OAuth 2.0 or any OpenID Connect provider, both with PKCE — or a password, described under Passwords.

  • The state value 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_attempts as rejected-code, not a server fault.
  • The email must already be a member with allowed = 1. Others are refused and the attempt recorded in auth_attempts.
  • The provider must say the address is verified (email_verified: true in 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 as unverified-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-most X-Forwarded-For entry — 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.

Authorisation

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.

Cross-site request forgery

Two independent controls:

  1. The session cookie is SameSite=Lax, and no GET handler in the router mutates state — verified by a test that walks every handler for write calls.
  2. Every unsafe method must carry an Origin matching the deployment, or a Referer from it when Origin is absent. Referrer-Policy: same-origin guarantees a same-origin request carries a Referer and 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.

Redirects

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.

Output encoding

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.

Input handling

  • SQL: every query is parameterised. The single dynamically-assembled UPDATE builds its SET clause 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 with nosniff; PDFs are served Content-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.

Transport and headers

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.

Secrets

  • statement_identity and gmail_connections are listed in NEVER_EXPORTED and 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.

Outbound requests

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.

Dependencies

None at runtime.

Passwords

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.

Errors the household is allowed to see

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.

The other hashes, and why they are not this

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.

Regexes that look like sanitizers, and are not

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.

There aren't any published security advisories