SlowBooks Pro v2.31.4 · saved from https://www.slowbookspro.com/docs/security-hardening/, where the guide for the latest version always is.

Security hardening

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:

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:

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:

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.


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.