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. Encryption at rest is the storage layer's job: on the hosted service the database, backups and attachments sit on a volume Fly.io encrypts at rest; on a self-hosted install it is whatever the disk provides. The application does not encrypt the file itself, because its key would have to live on the same machine (the reasoning of 08 S9); stored credentials are sealed under SECRETS_KEY. Every change is recorded in the event log with its actor; reads are not logged (R37.7), except reads by an AI connection, which go to ai_access_log. 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. This is an absolute lifetime, not an idle timeout, so a stolen cookie that is kept busy still expires on schedule. Signing in again starts a new one. Sessions can be revoked individually from Settings, but only your own. 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.

AI app connections (OAuth). An MCP client (claude.ai, ChatGPT, Gemini, Claude Code) connects to /mcp through a small OAuth 2.1 authorization server in src/auth/oauth.ts. What it ends up holding is an API token as above, with read scope unless changes were allowed (below) and a one-hour lifetime, so nothing about tokens needed a second set of rules. The new surface, and what holds it:

  • Registration is open, because these clients register themselves (RFC 7591) with no account. So a registration proves nothing: the consent page leads with the redirect URI's host, which the client cannot fake, not its self-chosen name, and warns when it only answers on loopback. Redirect URIs are https, or http on loopback; never javascript:, a fragment or user:pass@. Registrations are rate limited per address, capped at 200, and pruned after a day if nobody approved them.
  • Consent needs a signed-in session, is refused while viewing as another member and on the demo, and its POST is an ordinary form post, held to the Origin and Host rules below. A redirect URI the client did not register is never followed: the error is shown on the page instead. The consent page's CSP adds the one redirect origin to form-action, so the browser can follow the answer there; no other page's policy changes.
  • Codes are SHA-256-hashed, ten minutes, single use, bound to the client, the redirect URI and a PKCE S256 challenge (compared in constant time). A code used twice revokes what its first use issued.
  • Refresh tokens are hashed, rotate on every use and last 90 days. An old one presented again revokes the whole connection, on the assumption that either holder may be the thief. Removing a member, or revoking the connection or its access token in Settings, ends the refresh token with it.
  • Scope is read unless three things agree. The deployment sets AI_WRITE to standard or full; only then is budget:write advertised and an "allow changes" box shown on the consent page, never pre-ticked whatever the app asked for. The member ticks it (stored on the grant, can_write). And the switch is read again at every token refresh, so turning it off makes every connection read-only within the hour, and the change tools vanish at once. The grant's own scope column stays read under its CHECK constraint.
  • An AI's token writes only through /mcp. Any token made on Connect an AI, and every OAuth access token, is marked for_ai; whatever its scope, a write to any other route is 403. Over /mcp a write is one of seven tools that call the same domain functions and visibility checks as the pages (no deletes, no loans, assets, month close, settings, members, tokens, imports, rules or schedules), capped at 100 a day and 50 suggestions waiting per connection, attributed with source ai, and undoable as a day's group (ai.md). A script's ordinary read-write token is not offered the change tools: the member opted in for an AI, not for it.
  • The machine-facing endpoints (the two /.well-known documents, /oauth/register, /oauth/token, /oauth/revoke) are public, read at most 64 KB, and are exempt from the same-origin rule (the provider's servers post from their own origin). They take codes and refresh tokens, never a cookie, and send Access-Control-Allow-Origin: * so browser-based clients can read the answer; a page that calls them learns nothing it could not ask for directly. /oauth is on the token deny-list, so a bearer can never approve more access.
  • /mcp without a usable token is answered 401 with WWW-Authenticate: Bearer resource_metadata=… before the same-origin rule runs, which is what starts a client's sign-in.

One deliberate departure from RFC 8707's audience idea: the token is issued for ${BASE_URL}/mcp, but as an API token it is also good on the read routes (/export.json and the like). Both are this server, under the same member's visibility rules. The same holds for a token made on Connect an AI. Those reads are not masked, whatever the connection chose (only /export/analysis.json is, AI-3), and they are not in the AI access log, which records /mcp. We chose to tell the member rather than close the routes: the consent page and Connect an AI both say it where the key is made (AI-6).

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, and a third that keeps the second honest:

  1. The session cookie is SameSite=Lax, and no GET handler in the router mutates state. This is 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.
  3. Before either is compared, the host the request was addressed to (Host, or the right-most X-Forwarded-Host entry behind TRUST_PROXY) must be BASE_URL's hostname (with BASE_URL's port, or none) or a name no DNS answer is involved in: an IP literal or localhost. Anything else is 403 on an unsafe method. Without this, DNS rebinding defeated (2): a page on evil.example re-points its own name at this machine and posts to it, so its Origin and the Host agree, and on a fresh LAN install with LOCAL_LOGIN it could create the first member through /auth/first-run. An IP literal or localhost cannot be rebound, so the LAN address a household types keeps working; any other name it uses must be the one in BASE_URL.

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.

Notices

The banner a redirect lands on is carried in the URL (?notice=), so a link could put its own words there (escaped, never a script, but in the app's own success banner). Every notice the server issues is signed (ns, an HMAC of the text keyed by a random per-process secret) and an unsigned or mismatched one is not shown. A restart between a redirect and its page drops that one notice. The CAS import page's error is a fixed key (?error=expired), not text.

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.

What the browser keeps

Nothing the server sends is stored on the device (08 R35). The one exception is the Add form's write-only queue (10 §3.7, R35.7): an entry that could not reach the server is kept in localStorage under pathayam.outbox.v1 until the server confirms it. The entry holds the fields as typed, the idempotency key, when it was kept, and the member id from the page's data-member attribute.

  • It is not encrypted. A key held by the same script on the same device would stop nobody who can already read the browser's storage (the reasoning of 08 S9). Anyone with the unlocked device and browser profile can read an unsent entry; nothing else is there to read.
  • Every entry is rendered with textContent, never as markup: it is what somebody typed, and a refusal message is what the server said.
  • A replay is an ordinary same-origin POST with the session cookie and the original Idempotency-Key, so it meets the same origin check, the same authorisation and the same R36.2 replay protection as the first attempt. A key is scoped to the member (R36.4), and an entry tagged with another member is discarded rather than sent.
  • Signing out purges it twice: the script clears it on submit, and the sign-out response carries Clear-Site-Data: "storage" for a page whose script never ran. "Sign out every device" cannot reach another device's storage; an entry left there is sent if the same member signs in again and discarded if anyone else does.
  • client.test.ts asserts that localStorage is reached in exactly one place, with that one key, and that sessionStorage, IndexedDB, the Cache API, a service worker and script-written cookies appear nowhere.

Secrets

  • statement_identity, gmail_connections and gmail_clients are listed in NEVER_EXPORTED and excluded from the JSON and CSV exports.
  • A Google client secret a household brings, and Gmail refresh tokens, are sealed at rest with AES-256-GCM under SECRETS_KEY (src/gmail/secrets.ts), each bound to its row, so a copied database file does not yield them. The key is set by the operator (a Fly secret, or .env), never stored beside the data; under 32 characters refuses to start. Without it, own Google keys cannot be saved. API tokens cannot reach /gmail/keys.
  • 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, the price providers, and NSE's and BSE's end-of-day files) or set by the operator: OIDC_ISSUER (and the endpoints its discovery document names), BACKUP_WEBHOOK_URL and HEARTBEAT_URL. User-supplied values reach only path segments and query values, through encodeURIComponent.

OpenID Connect discovery ({OIDC_ISSUER}/.well-known/openid-configuration) is bounded at ten seconds, like the price providers' fetches; a provider that does not answer in time makes /auth/oidc answer 503, not hang.

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.
  • Removing a member deletes their password, as it revokes their sessions and API tokens. Adding the address back restores the member and everything they entered, not the credentials they held: they set a new password, and the person re-adding them is told so. Otherwise re-adding somebody silently reopened a password known to whoever learned it while they were away. Removal also deletes any password link made for them and not yet used.

Password links

A household with no Google or OIDC sign-in needs some way to give a newly invited (or re-added) member their first password: /settings/password needs a session, and /auth/first-run closes once anybody is in. A password link is that way. Code: src/auth/setup-links.ts.

  • The token is 32 random bytes, base64url. Only its SHA-256 is stored, in password_setup_links, so a copy of the database, a backup or an export holds no working link. The raw token is shown once, either rendered on the page that made it (never in a redirect, a notice or a query string) or printed by the server command. It is kept out of the event log (which records that a link was made and used, by whom and for whom, never the token or its hash) and out of logged paths (redactedPath in src/http/server.ts, used for log lines and request_failures).
  • Expiry and single use. A link lasts 72 hours and sets a password once. Making another for the same member deletes their earlier unused ones, so at most one works. A link that is unknown, used, expired, or no longer suitable (below) answers in one set of words with 410, so a holder of a stale link learns nothing about which it is.
  • The page. /auth/setup/<token> works signed out. It is sent with Cache-Control: no-store and Referrer-Policy: strict-origin, so the path, which is the credential, is never sent as a Referer, not even to this app's own stylesheet. Not no-referrer: under that policy browsers send Origin: null on the form's POST, which the same-origin write check refuses; strict-origin gives the same guarantee for the token and leaves that check untouched. The POST is rate limited per source address like /auth/password, and a link that does not work counts as a failed attempt. A password is set in one transaction with the link being spent; one the rules refuse leaves the link usable.

Who may make one. Any signed-in member, for another member. All members are peers, and any of them can already add people to the household. Not on the demo, and not while viewing as somebody else. The target must be in the household, allowed in, and have no password. Four refusals carry the weight:

  1. Not for yourself. You have a session; set your own password in Settings.
  2. Not for somebody who already has a password. A link sets a password without asking for the old one, so it is a reset. A reset another member can perform is a takeover, so they change their own in Settings, and a forgotten one is reset by whoever runs the server.
  3. Not for somebody who has ever signed in. A member who signs in with Google or OIDC has no password either, and a link made for them would add a second way in that they never asked for: a takeover by another route. Every sign-in sets last_seen_at, so only somebody who has never been in counts as a newcomer; anyone else sets their own password in Settings or gets a link from whoever runs the server.
  4. Not for somebody who holds records the issuer cannot see: a private account or insurance policy, a personal budget with anything in it, saved statement-password details (PAN, date of birth), a connected mailbox, or tax figures. Whoever makes a link can use it instead of passing it on: set the password, sign in as that member and read everything private to them. Per-member privacy (docs/privacy.md) would hold only until somebody pressed a button. The refusal says only that there is something private, never what, and names the server command.

The last three rules are checked again when the link is used, against the member who made it: a link made for a newcomer who has since opened a private account, signed in some other way, or set a password, does not work, and nor does one made by a member who has since been removed.

The server command (npm run password-link, in operations) is how the refused cases are handled. It is held only to the rules about the target (in the household, and no password unless --reset is given), because whoever runs the server can already read the database file (out of scope above), so a link from there discloses nothing new. --reset clears an existing password and signs the member out everywhere before the link is made.

Errors the household is allowed to see

There are three kinds:

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. 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 enforces this: 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. Replacing MD5 with scrypt there would not harden the decryptor; it would stop it opening 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 only the first line of it.

src/auth/tokens.ts is reported the same way: it hashes API tokens and OAuth codes and refresh tokens with SHA-256. Each of those is 32 random bytes the server generated, so there is no password to guess and a slow hash would only slow every request, which is authenticated by looking the hash up.

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 three permanent known-good entries would make the security tab easy to ignore.

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