Security Overview
Supported Versions
Section titled “Supported Versions”| Version | Supported |
|---|---|
| latest | Yes |
Reporting a Vulnerability
Section titled “Reporting a Vulnerability”If you discover a security vulnerability in Patrimoine:
- Do NOT open a public issue.
- Email the maintainer directly with details of the vulnerability.
- Include steps to reproduce, impact assessment, and suggested fix if possible.
- You will receive a response within 48 hours.
Security Measures
Section titled “Security Measures”Patrimoine implements defense-in-depth across the following categories:
-
Authentication
- Dual system: OIDC (Authorization Code + PKCE via Authentik) with RS256 JWT validation through JWKS, and local auth with HS256 JWT signed by
JWT_SECRET. - The
issclaim routes tokens to the correct validator. JWT_SECRETmust be at least 32 characters or the app refuses to start.- Refresh tokens are rotated on every use for both auth paths (OIDC when the provider returns a new one; local auth unconditionally).
- Local login/TOTP additionally locks a client IP out of an account for
LOGIN_LOCKOUT_MINUTES(doubling, up toLOGIN_LOCKOUT_MAX_MINUTES) afterLOGIN_MAX_ATTEMPTSconsecutive wrong guesses from that IP, so an attacker cannot lock the owner out. A much higherLOGIN_ACCOUNT_MAX_ATTEMPTSacross all IPs locks the whole account. This is on top of the IP-based rate limit below. - See authentication.md.
- Dual system: OIDC (Authorization Code + PKCE via Authentik) with RS256 JWT validation through JWKS, and local auth with HS256 JWT signed by
-
Cookies — All cookies are
HttpOnlywithPath=/. TheSecureflag is protocol-aware:is_https()checksX-Forwarded-Protowhentrusted_proxy=true, falling back torequest.url.scheme.SameSite=Laxis used for all cookies (session and OIDC flow) —SameSite=Nonewas rejected because it breaks the OIDC callback in Firefox (seedocs/security/audit-trail.mdin the repository). See cookies-and-csrf.md. -
CSRF — The
X-Requested-Withheader is required on all mutations (POST/PATCH/PUT/DELETE). Safe methods (GET/HEAD/OPTIONS),/health, E2E test mode, andBearer ptr_API tokens are exempt. There is no/auth/prefix exemption: login and registration also require the header. See cookies-and-csrf.md. -
Rate limiting
- Sliding window in Python middleware, bucketed by authenticated user (verified local-JWT
sub) when available, else IP (/auth/10r/60s,/api/v1/import/5r/60s,/api/v1/400r/60s) plus nginxlimit_req_zone(API 10r/s burst 20, auth 5r/s burst 10). - Uses
X-Real-IP(set by nginx, non-spoofable) instead ofX-Forwarded-Forwhentrusted_proxy=true. - API tokens and OIDC sessions stay IP-based — see
app/middleware/rate_limit.pyfor why.
- Sliding window in Python middleware, bucketed by authenticated user (verified local-JWT
-
User isolation — All database queries filter by
user_id. Every endpoint depends onget_current_user, which setsrequest.state.user_id. Verified by tests. -
Admin role
- Optional
is_adminflag. - Bootstrapped for OIDC accounts via
ADMIN_EMAILS/ADMIN_SUBS(one-way: removing an entry never demotes an already-admin user). Local accounts are never matched, since a self-registered email is not proof of ownership; on a local-auth instance the first account registered on an empty instance becomes admin. - After that, an existing admin can promote or demote other users from the admin console.
- Admin-only endpoints (
/api/v1/admin/*) depend onget_current_admin_user, which wrapsget_current_userand 403s non-admins. - Scoped strictly to account management (enable/disable, promote/demote, invite) — an admin can never query another user’s financial data.
- A server-side last-admin guard blocks any action that would leave zero admins.
- Disabled (
is_active=false) accounts are rejected both at login and on every subsequent authenticated request. - See authentication.md.
- Optional
-
Container hardening — Non-root UID 10001, read-only filesystem,
cap_drop: ALL,no-new-privileges: true. Enforced in bothdocker-compose.ymlandpodman.sh. -
Security headers — CSP (
script-src 'self', no inline scripts),X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, HSTS,Permissions-Policy. Configured infrontend/container/nginx.conf.template,web/container/nginx.conf.templateandCaddyfile.example. The frontend’s nginx sendsReferrer-Policy: no-referrerinstead on the reset-password, accept-invite and confirm-email pages, whose emailed links carry a secret token in the query string; those pages check the token with a POST body, never a URL. -
API tokens —
ptr_-prefixed, SHA-256 hashed at rest, plaintext shown only once at creation, 10 per user by default (MAX_TOKENS_PER_USER). Authenticated viaAuthorization: Bearer ptr_...header (CSRF-exempt since no cookie is involved). Requests authenticated with a token are markedapi_tokenin the detail of their audit log entry, including calls to admin routes; the Android app uses a token for every call, so admin routes cannot refuse tokens. A personal API token has full account scope: it can do anything its owner can, including listing and ending the owner’s sessions, so treat it like a password. -
Webhooks
- HMAC-SHA256 signature via
X-Patrimoine-Signatureheader. - HTTPS-only URLs.
- SSRF protection: private IP blocklist (RFC 1918, loopback, link-local, reserved, multicast) + CGNAT (RFC 6598
100.64.0.0/10) + DNS pinning (resolve once, pin the IP for the httpx connection). - SSL hostname verification with
sni_hostnameextension. - Redirect-following disabled.
- Fire-and-forget via
BackgroundTasks. - Max 10 per user.
- Timestamp +
event_idfor replay protection. - URL redacted in logs.
- HMAC-SHA256 signature via
-
Metrics —
GET /metricsis off unlessMETRICS_TOKEN(at least 32 characters) is set; it answers 404 when disabled and 401 for a missing or wrong bearer token. The frontend’s nginx does not proxy it, so it is reachable only on the container network. Labels are bounded (route templates, status classes) and carry no user data. -
Audit logging
- Auth events (
/auth/*) and data mutations (POST/PATCH/PUT/DELETE to/api/v1/*) persisted to DB with IP, status,user_id, method, path, duration. - Configurable retention via
AUDIT_LOG_RETENTION_DAYS(default 90). - Security events (failed sign-ins, recorded with a SHA-256 hash of the email rather than the address; account lockouts; logouts; password change and reset; two-factor enable and disable; recovery code used and recovery codes generated; a session revoked and the other sessions revoked; API token creation and revocation) are written as audit entries of their own. Audit entries never contain a recovery code.
- Every response carries an
X-Request-IDheader (a valid one sent by the client is kept, anything else is replaced), and the same id plus the signed-in user id appear on every JSON log line of that request, so a log entry can be traced back to one request. - Background cleanup task.
- Paginated
GET /api/v1/audit-logsendpoint. - See audit-trail.md (
docs/security/audit-trail.mdin the repository).
- Auth events (
-
CI/CD — gitleaks secret scanning on every push/PR (
fetch-depth: 0).pip-auditfor Python dependencies.npm audit --omit=devfor production frontend dependencies. Self-hostedrockyrunner based onrockylinux:9-minimal.concurrencygroup withcancel-in-progressprevents stale CI runs. -
Backup —
set -eu,umask 077,chmod 600on backup files. Temp.netrcfor WebDAV credentials (not-uflag).mktempfor temp files. Single-quote escaping in sqlite3.backupcommand to prevent shell injection. Pythonsqlite3.connect().backup()for internal backup (eliminates shell injection vector).
Related Documentation
Section titled “Related Documentation”- Authentication — Dual auth system, token validation, OIDC flow, API tokens
- Cookies & CSRF — Cookie helpers, SameSite rationale, CSRF middleware
- Audit Trail (
docs/security/audit-trail.mdin the repository) — Full threat register (96 findings across 12 audits) - Deployment Checklist — Production security checklist