Security model¶
What the app protects¶
- Passwords are hashed with argon2id and never stored or logged in any other form.
- Sessions are server-side. The cookie holds a 256-bit random token;
the database stores only its SHA-256. It is
HttpOnly,SameSite=Lax, andSecurewhen you enableauth.cookie_secure. - Session lifetime is bounded three ways: an idle window (default one hour), an absolute cap from creation (default seven days) that activity cannot extend, and the stored expiry. A page left open warns you and then signs you out.
- CSRF — every state-changing request must echo a session-bound token.
- Login lockout counts failures per account, from every address together, so spreading guesses across many machines gains nothing. It covers the two-factor code step as well as the password. The trade-off: anyone who knows your email address can keep the password sign-in locked by failing on purpose. Passkeys and single sign-on still work while it lasts.
- Two-factor authentication (TOTP) with single-use recovery codes. A correct password alone reaches no page at all.
- Between portfolios, every query for personal data is filtered automatically. A query with no portfolio context raises rather than returning everything.
- API keys belong to the person who made them, are stored as hashes, reach only the portfolios chosen for them, and are read-only unless granted write. A key never does more than its creator can do now: deactivate them and their keys stop; remove them from a portfolio and their keys stop reaching it; make them a viewer and a write key reads only there.
What it does not protect against, and why¶
This is the part worth reading.
Anyone who can reach the database can read everything¶
Holdings, session token hashes, TOTP secrets, the lot. sqlite3 stocktake.db,
a copy of the volume, a backup snapshot, or a shell in the container all bypass
every control above.
This is not a bug to be fixed later. Real separation would need per-user encryption keyed on your password — which would also lock out the price feed, the reports, and the backups, since none of those have your password. The trade was made deliberately.
The boundary is "can reach the machine or its storage", not "has an account". Secure the machine accordingly.
People you share a portfolio with are trusted¶
Roles (owner, member, viewer) stop accidents, not adversaries. In
particular the instrument catalogue and price history are shared across the
whole instance and writable by any member: someone could repoint an
instrument's price symbol and change what everyone's charts show.
An instance is for one household, or people who trust each other. It is not multi-tenant hosting.
Plain HTTP is plain¶
Over HTTP on a LAN, session cookies and page contents are readable by anyone on
that network. If that matters, put TLS in front of it and set
auth.cookie_secure: true and auth.trusted_proxies.
Passkeys and security keys additionally require HTTPS — a browser rule, not
ours. They are offered once auth.webauthn names your domain. Behind a proxy
that terminates TLS they also need auth.trusted_proxies, or the app cannot
tell the connection is encrypted and does not offer them.
A passkey signs you in on its own, with no password and no authenticator code. That is not a shortcut around the second factor: unlocking one requires a fingerprint, face or PIN, so the sign-in already proves both that you hold the device and that it is you. The app enforces that rather than trusting it — an authenticator that reports only "someone was present" is refused.
An admin can reset anyone¶
Admins can reset passwords and clear another user's two-factor. Anyone with
shell access can do the same with python -m app.recover. That is the intended
recovery path for a self-hosted app; it also means the admin is trusted.
Reporting a vulnerability¶
Please report privately rather than opening a public issue. The security policy says how, what happens next, and which versions get fixes.
Useful reports say what an attacker can do that they should not be able to. "Anyone with the database file can read holdings" is documented above and not a vulnerability; "a viewer can write trades" would be.
Hardening checklist¶
- [ ] TLS in front,
auth.cookie_secure: true,auth.trusted_proxiesset to your proxy - [ ] Two-factor enabled on every account
- [ ] Recovery codes stored somewhere that is not the phone with the authenticator on it
- [ ] The database file backed up, and the backup protected as carefully as the database
- [ ] Not exposed to the internet unless you meant to