Docs Security and admin

Security hardening

For SlowBooks Pro v2.31.4 · Download this guide (one HTML file) · Source on GitHub

This is the engineering log of the production-readiness security pass. It records what each change does, where it lives, and how it was tested, so future maintainers can reason about the threat model without re-deriving it.

For the public security policy and responsible-disclosure address, see ../SECURITY.md.


Hardening Pass Summary

#AreaWhat changedWhere
1Startup checksFail hard in production, or on any non-SQLite database, if PAYROLL_ENCRYPTION_SECRET is the dev default or empty; on every install, the desktop included, if it was typed shorter than 16 charactersapp/main.py:_run_startup_security_checks()
2Startup checksFail hard in production if DATABASE_URL does not specify sslmodeapp/main.py:startup_security_checks()
3Startup checksFail hard in production if FORCE_HTTPS=falseapp/main.py:startup_security_checks()
4TransportApp-level HTTPโ†’HTTPS redirect via HTTPSRedirectMiddlewareapp/main.py
5TransportStrict-Transport-Security: max-age=63072000; includeSubDomains; preloadapp/main.py:security_headers()
6TransportSession cookie Secure flag tied to FORCE_HTTPSapp/main.py
7XSSContent-Security-Policy (frame-ancestors none, object-src none, form-action self)app/main.py:_CSP
8Header modelsecurity_headers middleware uses setdefault semantics so routes can overrideapp/main.py:_set_if_unset()
9PortalToken expiration โ€” 90-day sliding idle + 1-year hard expiryapp/routes/portal.py, app/models/payroll.py
10PortalReferrer-Policy: no-referrer + Cache-Control: no-store on portal responsesapp/routes/portal.py:_PORTAL_HEADERS
11PortalRate limiting on every portal endpoint (30/min GET, 10/min POST)app/routes/portal.py
12EncryptionCiphertext now prefixed with v1: to enable clean key rotationapp/services/encryption.py
13EncryptionSupport PAYROLL_ENCRYPTION_SECRET_PREV for in-flight rotationapp/services/encryption.py

The full suite passes after every change.


Why each change matters

Startup fail-hard checks

APP_DEBUG=false is the production switch. These checks run at app startup and the process exits if any fails:

  1. PAYROLL_ENCRYPTION_SECRET must not be the well-known dev default, empty or shorter than 16 characters. If it were, anyone with the source code could decrypt bank PII at rest. This one also fires with APP_DEBUG=true whenever the database is not SQLite, and the short case fires on every install, the desktop included (2.31.3: .env.example ships the line empty, and an empty value passed; a typed short value passed on SQLite).
  2. DATABASE_URL must include sslmode (Postgres) or ssl= (other drivers). Without TLS, payroll data crosses the wire in plaintext.
  3. FORCE_HTTPS must be true. Without it, the app would happily serve over plain HTTP, leaking session cookies and portal tokens.

SLOWBOOKS_PRIVATE_NETWORK=1 (set by the single-host docker-compose.yml) skips checks 2 and 3 only, with a warning. Three more refusals are not about secrets: a configured RATE_LIMIT_STORAGE_URI whose Redis cannot be reached, an encrypted company file with no key, and a database behind the migration head. The reasoning is the same in every case: the cheapest moment to fix a config mistake is before the app accepts its first request.

App-level HTTPS + HSTS

Production deployments terminate TLS at a reverse proxy, but defense in depth says the app shouldn't trust the proxy unconditionally. With FORCE_HTTPS=true:

  • HTTPSRedirectMiddleware 307-redirects plain HTTP to HTTPS at the app. Behind a TLS proxy it is a no-op once FORWARDED_ALLOW_IPS names the proxy, because the forwarded scheme is HTTPS; without that setting the app sees plain HTTP on every request and redirects forever (ERR_TOO_MANY_REDIRECTS), see HTTPS with a proxy.
  • HSTS tells browsers to refuse plain HTTP for two years. The includeSubDomains and preload directives are required for HSTS preload-list submission.
  • The session cookie carries the Secure flag, so the browser will refuse to send it over an HTTP downgrade.

Content-Security-Policy

The previous header set blocked clickjacking (X-Frame-Options: DENY), MIME sniffing, and referrer leakage but did nothing against script injection. CSP fills that gap:

default-src 'self';
script-src 'self' 'unsafe-inline' https://js.stripe.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self' https://api.stripe.com;
frame-src https://js.stripe.com https://hooks.stripe.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
object-src 'none'

'unsafe-inline' is still in script-src and style-src. Honest accounting:

  • index.html โ€” zero inline event handlers as of the recent cleanup. The 11 onclick=/oninput= attributes that used to live here moved to app/static/js/bootstrap.js, which wires them via addEventListener on DOMContentLoaded. The static shell page would work under a stricter CSP today.
  • JS-rendered modals still use inline onclick="Foo.bar(x)" in template-literal HTML strings across roughly two dozen app/static/js/*.js files. Removing 'unsafe-inline' would require converting each to either addEventListener after innerHTML, or a delegated data-action dispatcher at the document level. That's a real multi-file refactor; tracked in docs/todo.md.
  • Inline style="..." attributes show up in those same JS-rendered modals, which is why style-src also still has 'unsafe-inline'.

Defense-in-depth gap, not an active vulnerability โ€” autoescape=True in every Jinja2 environment blocks XSS at the template level. The CSP would catch a hypothetical autoescape bypass; today it can't.

Portal token expiration

Portal tokens are 192 bits of entropy from secrets.token_urlsafe(24) โ€” unguessable. But a URL containing one can leak via browser history, server access logs, or shared bookmarks, so we need time-based mitigation:

  • Hard expiry: 1 year from issuance. Stored in Employee.portal_token_expires_at.
  • Idle expiry: 90 days of inactivity. Every authenticated request rolls Employee.portal_token_last_used forward, so an actively used token never expires before the 1-year wall.

Expired tokens return 410 Gone (rather than 404) so the SPA can distinguish "wrong link" from "your link aged out." Admins can mint a fresh token with POST /api/employees/{id}/portal-token.

The _to_utc() helper normalizes naive timestamps (SQLite) and aware ones (Postgres) so the comparison works in both environments.

Portal no-referrer

Referrer-Policy: no-referrer and Cache-Control: no-store ship on every portal HTML response and redirect. The first prevents the URL โ€” which contains the token โ€” from leaking to other origins via the Referer header. The second keeps the page out of intermediate caches.

The app-level security_headers middleware uses setdefault semantics, so the portal can override the default strict-origin-when-cross-origin without the middleware clobbering it.

Encryption key versioning

Previously, rotating PAYROLL_ENCRYPTION_SECRET would have required decrypting every stored ciphertext with the old key, then re-encrypting with the new โ€” a destructive, downtime-prone migration. Now:

  1. Move the live secret into PAYROLL_ENCRYPTION_SECRET_PREV.
  2. Set the new secret as PAYROLL_ENCRYPTION_SECRET.
  3. Bounce the app. New writes use the new key; old reads transparently fall through to the previous key.
  4. Run a re-wrap migration when convenient (no rush โ€” both keys work).
  5. Drop PAYROLL_ENCRYPTION_SECRET_PREV once everything is rewrapped.

Ciphertext is now prefixed with v1: so older unversioned blobs still decrypt cleanly. Decrypt tries the current key first, then any configured previous key.

Company file encryption (SQLCipher, #290)

A desktop company is one SQLite file. Turned on, it is a SQLCipher file: every page is encrypted under the driver (AES-256-CBC with a per-page HMAC), WAL and random access keep working, and a backup is a copy of ciphertext under the same key.

  • The engine takes sqlcipher3 in place of sqlite3 and keys each connection first, before the WAL and secure_delete PRAGMAs. The key goes in as a raw key (x'โ€ฆ'), so SQLCipher's own KDF does not run on every checkout from the pool.
  • The key is found once, before the file is opened: SLOWBOOKS_COMPANY_FILE_KEY (the launcher's hand-off to the server child and to the migration) or COMPANY_FILE_ENCRYPTION_SECRET (an operator's, for Server Edition), derived with PBKDF2-HMAC-SHA256 at 480,000 rounds under the salt slowbooks-company-file-v1, so it never equals the payroll key however the secrets are set.
  • An encrypted file with no key, or the wrong key, stops the start with one sentence; SQLCipher's ERROR CORE lines are silenced on every connection (PRAGMA cipher_log_level = NONE).
  • Turning it on or off is POST /api/company-file/encryption: administrator only (the role middleware and the route), the company's name typed to confirm, the file rewritten in place by SQLCipher's export under a write lock.
  • PostgreSQL deployments are the database host's job, and the Settings page says so.
  • The desktop's own key (2.24.0): with no operator secret set, turning encryption on makes a recovery key (160 random bits, shown once as eight groups of four characters, downloadable as a text file) and derives the file's key from it (PBKDF2-HMAC-SHA256, 480,000 rounds, salt slowbooks-company-recovery-v1), so the recovery key alone opens the file, and every backup of it, on any machine. The key is wrapped for each administrator's password (PBKDF2 under a per-wrapping salt, then Fernet) in <company>.db.keyring beside the file; the keyring holds nothing secret. The picker unlocks the file with an administrator's sign-in or the recovery key and hands the key to the server child the way it hands any key (SLOWBOOKS_COMPANY_FILE_KEY); the key is never written to disk. An administrator with no wrapping (added later, or after a password change) gets one at their next sign-in from the running process; one removed, demoted or reset loses theirs. A new recovery key re-keys the file in place (PRAGMA rekey) and retires the old one; a wrapping names the key it holds, so a stale one is told from a current one. A restore of a backup made under another key asks for the recovery key it was made under and re-keys a staged copy; the backup is never changed. docs/company-file-encryption.md is the owner's page.

OWASP Top 10 coverage

RiskMitigation
A01 โ€” Broken Access ControlNamed users in four roles (administrator, bookkeeper, accountant, read-only) enforced centrally; scoped, revocable API tokens that are never administrators; sessions ended on deactivation, role change or password reset; portal token expiry; rate limiting
A02 โ€” Cryptographic FailuresTLS enforced (transport) + Fernet at rest + key rotation path
A03 โ€” InjectionSQLAlchemy ORM throughout; one raw CREATE DATABASE guarded by allowlist regex; AST-audited subprocess callsites (zero shell=True)
A04 โ€” Insecure DesignStartup checks fail hard on critical misconfig
A05 โ€” Security MisconfigurationCSP, HSTS, security headers, secure cookie, locked-down CORS
A06 โ€” Vulnerable ComponentsPinned versions, upper-bound caps in requirements.txt
A07 โ€” Identification & Authentication FailuresArgon2id passwords, 5/min rate-limited login, 192-bit portal tokens
A08 โ€” Software & Data IntegrityFernet ciphertext is authenticated (AES + HMAC); session cookie signed
A09 โ€” Logging & MonitoringNo PII (passwords, SSN, account #) in logs; audit hooks on row writes
A10 โ€” SSRFAI provider URLs validated against private IPs and metadata endpoints

Production deployment checklist

Before exposing the app to anything other than localhost:

# Required env vars (process exits at startup if any are wrong)
export APP_DEBUG=false
export PAYROLL_ENCRYPTION_SECRET=$(openssl rand -base64 32)
export DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require"
export FORCE_HTTPS=true                  # default in production, explicit is better
export FORWARDED_ALLOW_IPS=127.0.0.1     # the proxy's address or CIDR, never 0.0.0.0/0
export CORS_ALLOW_ORIGINS="https://yourdomain.com"
export SESSION_SECRET_KEY=$(openssl rand -base64 48)  # else autogenerated to a file
export APP_WORKERS=1                     # or run a Redis and set RATE_LIMIT_STORAGE_URI

# Recommended
export RATE_LIMIT_ENABLED=1              # default on; set to 0 only for load tests
export HSTS_MAX_AGE=63072000             # 2 years, the preload-list minimum

Then run behind Caddy, nginx or Traefik with a real TLS cert. The app's own HTTPSRedirectMiddleware then becomes a no-op because the proxy forwards the HTTPS scheme, from an address FORWARDED_ALLOW_IPS trusts.


Frontend โ†” Backend wiring audit

After the security pass we ran a spider-web audit of every API.* call against every @router decorator to catch disconnects. See wiring-audit.md for the methodology and findings โ€” four real breakages were found and fixed.


Test coverage

Security-relevant tests live in tests/test_tier3.py:

test_portal_token_hard_expiry_blocks_access
test_portal_token_idle_expiry_blocks_access
test_portal_last_used_rolls_forward_on_access
test_portal_responses_send_no_referrer_header
test_portal_token_mint_sets_expiry
test_security_headers_present
test_encryption_roundtrip_with_version_prefix
test_encryption_decrypts_legacy_unprefixed_ciphertext
test_encryption_returns_none_for_garbage
test_portal_bank_account_encryption          (Fernet at rest)
test_portal_invalid_bank_account_routing     (input validation)
test_portal_dashboard_requires_valid_token   (auth)
test_portal_w4_update                        (token-gated mutation)

Run the full suite:

python -m pytest tests/ -q

Shell-injection surface audit

tests/test_subprocess_safety_audit.py is a CI-gated AST-based regression test that verifies:

  1. Zero subprocess.* calls use shell=True anywhere under app/ or scripts/.
  2. Zero os.system / os.popen / commands.getoutput calls anywhere in production code.
  3. Every bash script in scripts/ double-quotes all $VAR expansions (regex audit).

The test walks the AST of every .py file using ast.NodeVisitor; the shell-script scan uses a regex that flags bare $ outside double-quotes. Both run in < 1 s. Adding any shell=True or os.system call will break CI immediately.