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
| # | Area | What changed | Where |
|---|---|---|---|
| 1 | Startup checks | Fail 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 characters | app/main.py:_run_startup_security_checks() |
| 2 | Startup checks | Fail hard in production if DATABASE_URL does not specify sslmode | app/main.py:startup_security_checks() |
| 3 | Startup checks | Fail hard in production if FORCE_HTTPS=false | app/main.py:startup_security_checks() |
| 4 | Transport | App-level HTTP→HTTPS redirect via HTTPSRedirectMiddleware | app/main.py |
| 5 | Transport | Strict-Transport-Security: max-age=63072000; includeSubDomains; preload | app/main.py:security_headers() |
| 6 | Transport | Session cookie Secure flag tied to FORCE_HTTPS | app/main.py |
| 7 | XSS | Content-Security-Policy (frame-ancestors none, object-src none, form-action self) | app/main.py:_CSP |
| 8 | Header model | security_headers middleware uses setdefault semantics so routes can override | app/main.py:_set_if_unset() |
| 9 | Portal | Token expiration — 90-day sliding idle + 1-year hard expiry | app/routes/portal.py, app/models/payroll.py |
| 10 | Portal | Referrer-Policy: no-referrer + Cache-Control: no-store on portal responses | app/routes/portal.py:_PORTAL_HEADERS |
| 11 | Portal | Rate limiting on every portal endpoint (30/min GET, 10/min POST) | app/routes/portal.py |
| 12 | Encryption | Ciphertext now prefixed with v1: to enable clean key rotation | app/services/encryption.py |
| 13 | Encryption | Support PAYROLL_ENCRYPTION_SECRET_PREV for in-flight rotation | app/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:
PAYROLL_ENCRYPTION_SECRETmust 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 withAPP_DEBUG=truewhenever the database is not SQLite, and the short case fires on every install, the desktop included (2.31.3:.env.exampleships the line empty, and an empty value passed; a typed short value passed on SQLite).DATABASE_URLmust includesslmode(Postgres) orssl=(other drivers). Without TLS, payroll data crosses the wire in plaintext.FORCE_HTTPSmust 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:
HTTPSRedirectMiddleware307-redirects plain HTTP to HTTPS at the app. Behind a TLS proxy it is a no-op onceFORWARDED_ALLOW_IPSnames 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
includeSubDomainsandpreloaddirectives are required for HSTS preload-list submission. - The session cookie carries the
Secureflag, 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 toapp/static/js/bootstrap.js, which wires them viaaddEventListeneron 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 dozenapp/static/js/*.jsfiles. Removing'unsafe-inline'would require converting each to eitheraddEventListenerafterinnerHTML, or a delegateddata-actiondispatcher at the document level. That's a real multi-file refactor; tracked indocs/todo.md. - Inline
style="..."attributes show up in those same JS-rendered modals, which is whystyle-srcalso 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_usedforward, 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:
- Move the live secret into
PAYROLL_ENCRYPTION_SECRET_PREV. - Set the new secret as
PAYROLL_ENCRYPTION_SECRET. - Bounce the app. New writes use the new key; old reads transparently fall through to the previous key.
- Run a re-wrap migration when convenient (no rush — both keys work).
- Drop
PAYROLL_ENCRYPTION_SECRET_PREVonce 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
sqlcipher3in place ofsqlite3and keys each connection first, before the WAL andsecure_deletePRAGMAs. 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) orCOMPANY_FILE_ENCRYPTION_SECRET(an operator's, for Server Edition), derived with PBKDF2-HMAC-SHA256 at 480,000 rounds under the saltslowbooks-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 CORElines 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.keyringbeside 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.mdis the owner's page.
OWASP Top 10 coverage
| Risk | Mitigation |
|---|---|
| A01 — Broken Access Control | Named 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 Failures | TLS enforced (transport) + Fernet at rest + key rotation path |
| A03 — Injection | SQLAlchemy ORM throughout; one raw CREATE DATABASE guarded by allowlist regex; AST-audited subprocess callsites (zero shell=True) |
| A04 — Insecure Design | Startup checks fail hard on critical misconfig |
| A05 — Security Misconfiguration | CSP, HSTS, security headers, secure cookie, locked-down CORS |
| A06 — Vulnerable Components | Pinned versions, upper-bound caps in requirements.txt |
| A07 — Identification & Authentication Failures | Argon2id passwords, 5/min rate-limited login, 192-bit portal tokens |
| A08 — Software & Data Integrity | Fernet ciphertext is authenticated (AES + HMAC); session cookie signed |
| A09 — Logging & Monitoring | No PII (passwords, SSN, account #) in logs; audit hooks on row writes |
| A10 — SSRF | AI 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:
- Zero
subprocess.*calls useshell=Trueanywhere underapp/orscripts/. - Zero
os.system/os.popen/commands.getoutputcalls anywhere in production code. - Every bash script in
scripts/double-quotes all$VARexpansions (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.