Fix audit findings: lost updates, token signing, dependency advisories

Blocking:
- Stock adjustments read-modified-wrote outside a transaction, so concurrent
  changes silently overwrote each other. Verified: 50 concurrent -1 requests
  moved a quantity of 100 to 99 rather than 50, while all 51 history rows were
  written, leaving the ledger disagreeing with the stock. Both adjust and patch
  now take SQLite's write lock up front.
- Session tokens joined payload and HMAC with "." and split on the last
  occurrence. A raw digest can contain that byte, so ~12% of issued tokens
  failed their own validator (measured 121/1000). The digest is fixed width;
  slice by length instead.
- starlette 0.41.3 and python-multipart 0.0.20 carried 15 advisories between
  them, including a FileResponse Range-header DoS reachable through the public
  static assets. Pinned starlette explicitly; python-multipart was unused.

Also:
- PATCH quantity now writes history, and a floored adjustment logs the delta it
  applied rather than the one requested, so the log sums to the stock.
- Renaming or deleting a category or location rebuilds the search index for
  every part beneath it; full paths are indexed, so "Workshop" finds Bin A3.
- Blank names, negative quantities and explicit nulls on NOT NULL columns are
  422s instead of silent writes or 500s; taxonomy routes 404 on missing ids,
  409 on duplicates, and reject indirect parent cycles.
- Security headers, HSTS behind X-Forwarded-Proto, Secure cookie via the
  forwarded scheme, content-hashed asset URLs so Cloudflare cannot serve stale
  frontend code, and a global failed-login throttle.
- README documents a WAL-safe backup; cp of parts.db alone could lose commits.

Checks go from 68 to 134, including a suite that runs against a real server
because lost updates only appear when requests genuinely overlap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-08-24 10:08:56 -04:00
parent ffe508b7f3
commit 7bdf276342
7 changed files with 800 additions and 171 deletions
+54 -8
View File
@@ -45,13 +45,20 @@ Then open http://127.0.0.1:8123.
## Tests
```sh
.venv/bin/python -m tests.test_api
.venv/bin/python -m tests.test_api # 122 checks, in-process
.venv/bin/python -m tests.test_concurrency # 12 checks, against a real uvicorn
```
Exercises the API end to end against a throwaway database — the auth gate,
nested categories and locations, search across every indexed field, filter
rollups, stock adjustment and history, patch semantics, and cascade behaviour
when a category or location is deleted.
`test_api` exercises the API end to end against a throwaway database — the auth
gate and session-token signing, nested categories and locations, search across
every indexed field, filter rollups, stock adjustment and history, patch
semantics including explicit nulls, taxonomy cycle rejection, security headers
and asset versioning, and login throttling.
`test_concurrency` needs a real server process, because a lost update only shows
up when two requests genuinely overlap inside SQLite. It fires overlapping
adjustments, patches and creates at one part and asserts the stock log always
sums to the stored quantity.
## Configuration
@@ -62,6 +69,20 @@ when a category or location is deleted.
| `PARTS_SESSION_DAYS` | Session lifetime, default 30. |
| `PARTS_AUTH` | `off` disables the login gate (LAN-only use). |
| `PARTS_DB` | SQLite path. `/data/parts.db` in the container. |
| `PARTS_LOGIN_MAX_FAILURES` | Failed logins allowed per window, default 10. |
| `PARTS_LOGIN_WINDOW` | Throttle window in seconds, default 300. |
| `PARTS_SECURE_COOKIE` | `auto` (default) trusts `X-Forwarded-Proto`; `on`/`off` force it. |
Rotating `PARTS_PASSWORD` alone does **not** invalidate existing sessions when
`PARTS_SECRET` is set independently — the cookie is signed with the secret.
Rotate both to revoke every outstanding cookie.
Failed logins are throttled on a **global** window rather than per source
address. Caddy appends to `X-Forwarded-For` instead of replacing it, so the
client-supplied end of that header is forgeable and a per-IP bucket would be
trivially evaded. There is one legitimate user, so a global cap costs nothing
real; the trade-off is that a guessing spray can block the login form for the
window. Existing sessions keep working throughout.
## Deploying
@@ -75,13 +96,23 @@ cd ~/srv/parts && sudo docker compose up -d --build
```
The database is in the `parts_parts_data` docker volume, which survives
rebuilds. To back it up:
rebuilds.
**Back it up with SQLite's backup API, not `cp`.** The database runs in WAL
mode, so recently committed rows may still live in `parts.db-wal` and copying
`parts.db` alone can silently lose them. `VACUUM INTO` takes a consistent
snapshot of a live database:
```sh
sudo docker run --rm -v parts_parts_data:/d -v "$PWD":/out alpine \
sh -c 'cp /d/parts.db /out/parts-backup.db'
docker exec parts python -c \
"import sqlite3; sqlite3.connect('/data/parts.db').execute(\"VACUUM INTO '/data/backup.db'\")"
docker cp parts:/data/backup.db ./parts-backup-$(date +%F).db
docker exec parts rm /data/backup.db
```
Restore by stopping the container, copying the file back over `/data/parts.db`
and deleting any leftover `-wal`/`-shm` alongside it.
## API
Everything under `/api` is JSON and cookie-authenticated. `GET /healthz` is open.
@@ -103,3 +134,18 @@ GET /api/stats
The "what could I build with what I'm holding?" idea is meant to arrive as
another consumer of these endpoints — the arbiter on `.8` already has the lane
routing and typed-action machinery for it — rather than as a fork of them.
## Hardening notes
Responses carry `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`
and a `Content-Security-Policy` that keeps the page to same-origin scripts and
no framing; HSTS is added when `X-Forwarded-Proto` says the original request was
HTTPS. The session cookie is HttpOnly, SameSite=Lax and Secure, and its
signature is a fixed-width HMAC appended to the payload rather than delimited —
a delimiter byte can occur inside a raw digest, which previously invalidated
about 12% of issued tokens.
Static assets are served under content-hashed URLs (`app.js?v=<sha>`), so
Cloudflare caching them for hours is harmless: a deploy changes the URL. The
bare, unhashed paths are served `no-cache` so nothing can pin stale frontend
code against a newer API.