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
+152 -1
View File
@@ -23,6 +23,22 @@ def check(label, condition, detail=""):
with TestClient(app) as client:
# --- session tokens survive every signature byte value ---
import app.auth as _auth
import base64 as _b64, hashlib as _hl, hmac as _hm
def _issue_at(exp):
payload = str(exp).encode()
return _b64.urlsafe_b64encode(
payload + _hm.new(_auth._secret(), payload, _hl.sha256).digest()).decode()
_base = int(__import__("time").time()) + 90 * 86400
_bad = [e for e in range(_base, _base + 3000) if not _auth.token_valid(_issue_at(e))]
check("no issued token fails its own validator", not _bad, f"{len(_bad)}/3000 failed")
check("tampered token rejected", not _auth.token_valid(_issue_at(_base)[:-2] + "AA"))
check("expired token rejected", not _auth.token_valid(_issue_at(int(__import__("time").time()) - 5)))
check("garbage token rejected", not _auth.token_valid("not-a-token"))
# --- auth gate ---
check("unauthenticated list is 401", client.get("/api/parts").status_code == 401)
check("wrong password rejected", client.post("/api/login", json={"password": "nope"}).status_code == 401)
@@ -189,7 +205,127 @@ with TestClient(app) as client:
s = client.get("/api/stats").json()
check("stats counts remaining parts", s["parts"] == 2, str(s))
check("stats counts low stock", s["low_stock"] == 2, str(s))
check("blank name rejected", client.post("/api/parts", json={"name": " "}).status_code in (201, 422))
check("whitespace-only name rejected",
client.post("/api/parts", json={"name": " "}).status_code == 422)
check("negative quantity rejected",
client.post("/api/parts", json={"name": "neg", "quantity": -5}).status_code == 422)
check("negative min_quantity rejected",
client.post("/api/parts", json={"name": "neg2", "min_quantity": -1}).status_code == 422)
check("blank unit rejected",
client.post("/api/parts", json={"name": "u", "unit": " "}).status_code == 422)
check("unknown category on create 404s",
client.post("/api/parts", json={"name": "ghost", "category_id": 999999}).status_code == 404)
# --- regression: quantity changes are always logged ---
r = client.post("/api/parts", json={"name": "log target", "quantity": 10})
logged = r.json()["id"]
before = len(client.get(f"/api/parts/{logged}/history").json()["items"])
client.patch(f"/api/parts/{logged}", json={"quantity": 3, "reason": "stocktake"})
hist = client.get(f"/api/parts/{logged}/history").json()["items"]
check("PATCH quantity writes history", len(hist) == before + 1, f"{before} -> {len(hist)}")
check("PATCH history records the signed delta", hist[0]["delta"] == -7, str(hist[0]["delta"]))
check("PATCH history keeps the reason", hist[0]["reason"] == "stocktake")
n_before = len(client.get(f"/api/parts/{logged}/history").json()["items"])
client.patch(f"/api/parts/{logged}", json={"name": "log target renamed"})
check("PATCH without quantity writes no history",
len(client.get(f"/api/parts/{logged}/history").json()["items"]) == n_before)
client.patch(f"/api/parts/{logged}", json={"quantity": 3})
check("PATCH to the same quantity writes no history",
len(client.get(f"/api/parts/{logged}/history").json()["items"]) == n_before)
# --- regression: a floored adjustment logs what was applied, not requested ---
r = client.post("/api/parts", json={"name": "floor target", "quantity": 7})
floored = r.json()["id"]
client.post(f"/api/parts/{floored}/adjust", json={"delta": -999, "reason": "floor"})
fh = client.get(f"/api/parts/{floored}/history").json()["items"]
check("floored adjustment logs the applied delta", fh[0]["delta"] == -7, str(fh[0]["delta"]))
check("floored adjustment lands at zero", fh[0]["quantity_after"] == 0)
check("stock log sums to current quantity",
sum(h["delta"] for h in fh) == client.get(f"/api/parts/{floored}").json()["quantity"])
# --- regression: renaming taxonomy rebuilds the search index ---
shelf = client.post("/api/locations", json={"name": "Old Shelf"}).json()["id"]
client.post("/api/parts", json={"name": "shelf widget", "location_id": shelf, "quantity": 1})
check("part found by its location name", client.get("/api/parts", params={"q": "Old Shelf"}).json()["total"] == 1)
client.patch(f"/api/locations/{shelf}", json={"name": "New Shelf"})
check("rename drops the stale location term",
client.get("/api/parts", params={"q": "Old Shelf"}).json()["total"] == 0)
check("rename indexes the new location term",
client.get("/api/parts", params={"q": "New Shelf"}).json()["total"] == 1)
# Nested: renaming a PARENT has to reindex everything beneath it, because
# the full path is what gets indexed.
outer = client.post("/api/locations", json={"name": "Old Room"}).json()["id"]
inner = client.post("/api/locations", json={"name": "Inner Bin", "parent_id": outer}).json()["id"]
client.post("/api/parts", json={"name": "nested widget", "location_id": inner, "quantity": 1})
check("part found by its parent location", client.get("/api/parts", params={"q": "Old Room"}).json()["total"] == 1)
client.patch(f"/api/locations/{outer}", json={"name": "New Room"})
check("parent rename reindexes descendants' parts",
client.get("/api/parts", params={"q": "Old Room"}).json()["total"] == 0
and client.get("/api/parts", params={"q": "New Room"}).json()["total"] == 1)
client.delete(f"/api/locations/{outer}")
check("deleting a location clears its term from the index",
client.get("/api/parts", params={"q": "New Room"}).json()["total"] == 0)
check("the part itself survives the delete",
client.get("/api/parts", params={"q": "nested widget"}).json()["total"] == 1)
# --- regression: PATCH null handling ---
check("explicit null name is a 422, not a 500",
client.patch(f"/api/parts/{logged}", json={"name": None}).status_code == 422)
check("explicit null unit is a 422",
client.patch(f"/api/parts/{logged}", json={"unit": None}).status_code == 422)
check("explicit null category_id is allowed",
client.patch(f"/api/parts/{logged}", json={"category_id": None}).status_code == 200)
check("explicit null cost_each is allowed",
client.patch(f"/api/parts/{logged}", json={"cost_each": None}).status_code == 200)
# --- regression: taxonomy cycles and missing ids ---
a = client.post("/api/categories", json={"name": "CycleA"}).json()["id"]
b = client.post("/api/categories", json={"name": "CycleB", "parent_id": a}).json()["id"]
c = client.post("/api/categories", json={"name": "CycleC", "parent_id": b}).json()["id"]
check("direct self-parent rejected",
client.patch(f"/api/categories/{a}", json={"name": "CycleA", "parent_id": a}).status_code == 400)
check("indirect cycle rejected",
client.patch(f"/api/categories/{a}", json={"name": "CycleA", "parent_id": b}).status_code == 400)
check("deep indirect cycle rejected",
client.patch(f"/api/categories/{a}", json={"name": "CycleA", "parent_id": c}).status_code == 400)
check("legitimate re-parent still allowed",
client.patch(f"/api/categories/{c}", json={"name": "CycleC", "parent_id": a}).status_code == 200)
check("PATCH missing category 404s",
client.patch("/api/categories/999999", json={"name": "ghost"}).status_code == 404)
check("DELETE missing category 404s", client.delete("/api/categories/999999").status_code == 404)
check("PATCH missing location 404s",
client.patch("/api/locations/999999", json={"name": "ghost"}).status_code == 404)
check("DELETE missing location 404s", client.delete("/api/locations/999999").status_code == 404)
check("unknown parent 404s",
client.post("/api/categories", json={"name": "orphan", "parent_id": 999999}).status_code == 404)
check("blank category name rejected",
client.post("/api/categories", json={"name": " "}).status_code == 422)
dup = client.post("/api/categories", json={"name": "DupTarget"}).json()["id"]
client.post("/api/categories", json={"name": "DupOther"})
check("PATCH into a duplicate name is a 409, not a 500",
client.patch(f"/api/categories/{dup}", json={"name": "DupOther"}).status_code == 409)
# --- security headers and asset caching ---
r = client.get("/")
check("nosniff header", r.headers.get("x-content-type-options") == "nosniff")
check("frame-options header", r.headers.get("x-frame-options") == "DENY")
check("referrer-policy header", r.headers.get("referrer-policy") == "no-referrer")
check("content-security-policy header", "default-src 'self'" in r.headers.get("content-security-policy", ""))
check("index is not cacheable", "no-store" in r.headers.get("cache-control", ""))
check("api responses are not cacheable",
"no-store" in client.get("/api/stats").headers.get("cache-control", ""))
check("index references content-hashed assets",
"/static/app.js?v=" in r.text and "/static/app.css?v=" in r.text)
import re as _re
digest = _re.search(r"/static/app\.js\?v=([0-9a-f]+)", r.text).group(1)
check("versioned asset is immutably cacheable",
"immutable" in client.get(f"/static/app.js?v={digest}").headers.get("cache-control", ""))
check("unversioned asset is not cached",
client.get("/static/app.js").headers.get("cache-control") == "no-cache")
check("hsts only when the original request was https",
"strict-transport-security" not in r.headers
and "strict-transport-security" in client.get("/", headers={"X-Forwarded-Proto": "https"}).headers)
# --- frontend & logout ---
check("index served", client.get("/").status_code == 200)
@@ -200,6 +336,21 @@ with TestClient(app) as client:
client.post("/api/logout")
check("logout clears session", client.get("/api/parts").status_code == 401)
# --- failed-login throttling (last: it trips global state deliberately) ---
auth_mod = __import__("app.auth", fromlist=["auth"])
auth_mod.clear_failures()
codes = [client.post("/api/login", json={"password": "wrong"}).status_code for _ in range(12)]
check("repeated wrong passwords start returning 429", 429 in codes, str(codes))
check("throttle kicks in only after the configured budget",
codes.index(429) == auth_mod._max_failures(), str(codes))
blocked = client.post("/api/login", json={"password": "hunter2"})
check("the correct password is refused while throttled", blocked.status_code == 429)
check("429 tells the client when to retry", blocked.headers.get("retry-after", "").isdigit())
auth_mod.clear_failures()
check("a successful login works again once the window clears",
client.post("/api/login", json={"password": "hunter2"}).status_code == 200)
check("a successful login resets the failure budget", auth_mod.login_retry_after() == 0)
print()
if failures:
print(f"{len(failures)} FAILED: " + "; ".join(failures))
+135
View File
@@ -0,0 +1,135 @@
"""Concurrency checks against a real uvicorn process.
The in-process test client is not enough here: a lost update needs two requests
genuinely overlapping inside SQLite, which means a real server and real threads.
"""
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
PORT = 8137
BASE = f"http://127.0.0.1:{PORT}"
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
failures = []
def check(label, condition, detail=""):
print((" PASS " if condition else " FAIL ") + label + (f" [{detail}]" if detail and not condition else ""))
if not condition:
failures.append(label)
def req(path, method="GET", body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(r, timeout=60))
def main():
tmp = tempfile.mkdtemp()
env = {
**os.environ,
"PARTS_DB": os.path.join(tmp, "conc.db"),
"PARTS_AUTH": "off",
}
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--port", str(PORT), "--log-level", "warning"],
cwd=ROOT, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
try:
for _ in range(120):
try:
req("/healthz")
break
except Exception:
time.sleep(0.25)
else:
raise RuntimeError("server never started")
# --- concurrent decrements must not lose updates ---
pid = req("/api/parts", "POST", {"name": "race target", "quantity": 100})["id"]
with ThreadPoolExecutor(max_workers=50) as ex:
codes = list(ex.map(
lambda _: req(f"/api/parts/{pid}/adjust", "POST", {"delta": -1, "reason": "race"}) and 200,
range(50)))
check("all 50 concurrent adjustments succeeded", codes.count(200) == 50)
final = req(f"/api/parts/{pid}")["quantity"]
check("50 concurrent -1 adjustments land at 50", final == 50, f"got {final}")
hist = req(f"/api/parts/{pid}/history?limit=500")["items"]
check("history has one row per change", len(hist) == 51, f"got {len(hist)}")
total = sum(h["delta"] for h in hist)
check("stock log sums to the stored quantity", total == final, f"log={total} stored={final}")
# --- mixed concurrent increments and decrements ---
# Start high enough that no intermediate ordering can hit the floor at
# zero: with the floor in play the net is legitimately order-dependent,
# which would make this assertion about clamping rather than atomicity.
start = 100.0
deltas = [5] * 30 + [-3] * 30
pid2 = req("/api/parts", "POST", {"name": "mixed target", "quantity": start})["id"]
with ThreadPoolExecutor(max_workers=30) as ex:
list(ex.map(lambda d: req(f"/api/parts/{pid2}/adjust", "POST", {"delta": d}), deltas))
got = req(f"/api/parts/{pid2}")["quantity"]
check("mixed concurrent adjustments net out correctly",
got == start + sum(deltas), f"got {got}, expected {start + sum(deltas)}")
h2 = req(f"/api/parts/{pid2}/history?limit=500")["items"]
check("mixed adjustment log sums to the stored quantity",
sum(x["delta"] for x in h2) == got, f"log={sum(x['delta'] for x in h2)} stored={got}")
# --- the floor at zero is still honoured under contention ---
pid_floor = req("/api/parts", "POST", {"name": "floor race", "quantity": 10})["id"]
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(lambda _: req(f"/api/parts/{pid_floor}/adjust", "POST", {"delta": -1}), range(20)))
fq = req(f"/api/parts/{pid_floor}")["quantity"]
fh = req(f"/api/parts/{pid_floor}/history?limit=500")["items"]
check("20 concurrent -1 on a stock of 10 floors at zero", fq == 0, f"got {fq}")
check("floored concurrent log still sums to the stored quantity",
sum(x["delta"] for x in fh) == fq, f"log={sum(x['delta'] for x in fh)} stored={fq}")
# --- concurrent PATCH quantity is logged exactly once per change ---
pid3 = req("/api/parts", "POST", {"name": "patch target", "quantity": 0})["id"]
with ThreadPoolExecutor(max_workers=20) as ex:
list(ex.map(lambda i: req(f"/api/parts/{pid3}", "PATCH", {"quantity": float(i + 1)}), range(20)))
h3 = req(f"/api/parts/{pid3}/history?limit=500")["items"]
stored = req(f"/api/parts/{pid3}")["quantity"]
check("concurrent PATCHes each logged a row", len(h3) == 20, f"got {len(h3)}")
check("PATCH log's final quantity_after matches stored",
h3[0]["quantity_after"] == stored, f"log={h3[0]['quantity_after']} stored={stored}")
# --- concurrent creates don't collide ---
with ThreadPoolExecutor(max_workers=25) as ex:
ids = list(ex.map(
lambda i: req("/api/parts", "POST", {"name": f"bulk {i}", "quantity": 1})["id"], range(25)))
check("25 concurrent creates produced 25 distinct parts", len(set(ids)) == 25)
check("all concurrent creates are searchable",
req("/api/parts?q=bulk&limit=100")["total"] == 25,
str(req("/api/parts?q=bulk&limit=100")["total"]))
finally:
server.send_signal(signal.SIGINT)
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
shutil.rmtree(tmp, ignore_errors=True)
print()
if failures:
print(f"{len(failures)} FAILED: " + "; ".join(failures))
return 1
print("all concurrency checks passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())