Skip to content

Security Overview

Version Supported
latest Yes

If you discover a security vulnerability in Patrimoine:

  1. Do NOT open a public issue.
  2. Email the maintainer directly with details of the vulnerability.
  3. Include steps to reproduce, impact assessment, and suggested fix if possible.
  4. You will receive a response within 48 hours.

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 iss claim routes tokens to the correct validator.
    • JWT_SECRET must 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 to LOGIN_LOCKOUT_MAX_MINUTES) after LOGIN_MAX_ATTEMPTS consecutive wrong guesses from that IP, so an attacker cannot lock the owner out. A much higher LOGIN_ACCOUNT_MAX_ATTEMPTS across all IPs locks the whole account. This is on top of the IP-based rate limit below.
    • See authentication.md.
  • Cookies — All cookies are HttpOnly with Path=/. The Secure flag is protocol-aware: is_https() checks X-Forwarded-Proto when trusted_proxy=true, falling back to request.url.scheme. SameSite=Lax is used for all cookies (session and OIDC flow) — SameSite=None was rejected because it breaks the OIDC callback in Firefox (see docs/security/audit-trail.md in the repository). See cookies-and-csrf.md.

  • CSRF — The X-Requested-With header is required on all mutations (POST/PATCH/PUT/DELETE). Safe methods (GET/HEAD/OPTIONS), /health, E2E test mode, and Bearer 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 nginx limit_req_zone (API 10r/s burst 20, auth 5r/s burst 10).
    • Uses X-Real-IP (set by nginx, non-spoofable) instead of X-Forwarded-For when trusted_proxy=true.
    • API tokens and OIDC sessions stay IP-based — see app/middleware/rate_limit.py for why.
  • User isolation — All database queries filter by user_id. Every endpoint depends on get_current_user, which sets request.state.user_id. Verified by tests.

  • Admin role

    • Optional is_admin flag.
    • 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 on get_current_admin_user, which wraps get_current_user and 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.
  • Container hardening — Non-root UID 10001, read-only filesystem, cap_drop: ALL, no-new-privileges: true. Enforced in both docker-compose.yml and podman.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 in frontend/container/nginx.conf.template, web/container/nginx.conf.template and Caddyfile.example. The frontend’s nginx sends Referrer-Policy: no-referrer instead 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 via Authorization: Bearer ptr_... header (CSRF-exempt since no cookie is involved). Requests authenticated with a token are marked api_token in 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-Signature header.
    • 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_hostname extension.
    • Redirect-following disabled.
    • Fire-and-forget via BackgroundTasks.
    • Max 10 per user.
    • Timestamp + event_id for replay protection.
    • URL redacted in logs.
  • Metrics — GET /metrics is off unless METRICS_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-ID header (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-logs endpoint.
    • See audit-trail.md (docs/security/audit-trail.md in the repository).
  • CI/CD — gitleaks secret scanning on every push/PR (fetch-depth: 0). pip-audit for Python dependencies. npm audit --omit=dev for production frontend dependencies. Self-hosted rocky runner based on rockylinux:9-minimal. concurrency group with cancel-in-progress prevents stale CI runs.

  • Backup — set -eu, umask 077, chmod 600 on backup files. Temp .netrc for WebDAV credentials (not -u flag). mktemp for temp files. Single-quote escaping in sqlite3 .backup command to prevent shell injection. Python sqlite3.connect().backup() for internal backup (eliminates shell injection vector).

  • Authentication — Dual auth system, token validation, OIDC flow, API tokens
  • Cookies & CSRF — Cookie helpers, SameSite rationale, CSRF middleware
  • Audit Trail (docs/security/audit-trail.md in the repository) — Full threat register (96 findings across 12 audits)
  • Deployment Checklist — Production security checklist