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:
@@ -150,31 +150,88 @@ def get_db():
|
||||
yield conn
|
||||
|
||||
|
||||
# --- tree helpers -----------------------------------------------------------
|
||||
|
||||
def tree_paths(conn, table: str) -> dict[int, str]:
|
||||
"""Map id -> 'Parent / Child' display path for a self-referencing table."""
|
||||
rows = conn.execute(f"SELECT id, name, parent_id FROM {table}").fetchall()
|
||||
by_id = {r["id"]: (r["name"], r["parent_id"]) for r in rows}
|
||||
paths: dict[int, str] = {}
|
||||
|
||||
def resolve(node_id: int, seen: set[int]) -> str:
|
||||
if node_id in paths:
|
||||
return paths[node_id]
|
||||
name, parent = by_id[node_id]
|
||||
# `seen` guards against a cycle introduced by a bad re-parent.
|
||||
if parent and parent in by_id and parent not in seen:
|
||||
path = resolve(parent, seen | {node_id}) + " / " + name
|
||||
else:
|
||||
path = name
|
||||
paths[node_id] = path
|
||||
return path
|
||||
|
||||
for node_id in by_id:
|
||||
resolve(node_id, set())
|
||||
return paths
|
||||
|
||||
|
||||
def descendants(conn, table: str, root_id: int) -> list[int]:
|
||||
"""A node id plus every id beneath it, so filtering by 'Electronics'
|
||||
catches parts filed under 'Electronics / Resistors'."""
|
||||
rows = conn.execute(f"SELECT id, parent_id FROM {table}").fetchall()
|
||||
children: dict[int, list[int]] = {}
|
||||
for r in rows:
|
||||
children.setdefault(r["parent_id"], []).append(r["id"])
|
||||
out, stack, seen = [], [root_id], set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if node in seen:
|
||||
continue
|
||||
seen.add(node)
|
||||
out.append(node)
|
||||
stack.extend(children.get(node, []))
|
||||
return out
|
||||
|
||||
|
||||
def begin_immediate(conn):
|
||||
"""Take SQLite's write lock up front.
|
||||
|
||||
Anything that reads a value, computes from it and writes it back must hold
|
||||
the write lock across all three steps. Python's sqlite3 only begins its
|
||||
implicit transaction at the first *write*, which leaves the preceding read
|
||||
outside the transaction — two concurrent stock adjustments would then read
|
||||
the same starting quantity and one would overwrite the other.
|
||||
"""
|
||||
if not conn.in_transaction:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
|
||||
# --- search index -----------------------------------------------------------
|
||||
|
||||
def reindex_part(conn, part_id: int):
|
||||
def reindex_part(conn, part_id: int, cat_paths=None, loc_paths=None):
|
||||
"""Rebuild one part's row in the FTS index.
|
||||
|
||||
The index is a plain (self-contained) FTS5 table rather than an
|
||||
external-content one: it costs a duplicate copy of some short text, and in
|
||||
exchange a delete is just `DELETE ... WHERE rowid = ?` instead of the
|
||||
contentless table's delete-with-original-values dance.
|
||||
|
||||
Full category and location *paths* are indexed, not just the leaf names, so
|
||||
searching "Workshop" finds what is sitting in "Workshop / Bin A3". That is
|
||||
also why renaming a node has to reindex everything beneath it.
|
||||
"""
|
||||
conn.execute("DELETE FROM parts_fts WHERE rowid = ?", (part_id,))
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT p.name, p.description, p.manufacturer, p.mpn,
|
||||
COALESCE(c.name, '') AS category,
|
||||
COALESCE(l.name, '') AS location
|
||||
FROM parts p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
LEFT JOIN locations l ON l.id = p.location_id
|
||||
WHERE p.id = ?
|
||||
""",
|
||||
"SELECT name, description, manufacturer, mpn, category_id, location_id "
|
||||
"FROM parts WHERE id = ?",
|
||||
(part_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
if cat_paths is None:
|
||||
cat_paths = tree_paths(conn, "categories")
|
||||
if loc_paths is None:
|
||||
loc_paths = tree_paths(conn, "locations")
|
||||
specs = conn.execute(
|
||||
"SELECT key, value FROM part_specs WHERE part_id = ?", (part_id,)
|
||||
).fetchall()
|
||||
@@ -191,15 +248,38 @@ def reindex_part(conn, part_id: int):
|
||||
""",
|
||||
(
|
||||
part_id, row["name"], row["description"], row["manufacturer"], row["mpn"],
|
||||
spec_text, tag_text, row["category"], row["location"],
|
||||
spec_text, tag_text,
|
||||
cat_paths.get(row["category_id"], "") if row["category_id"] else "",
|
||||
loc_paths.get(row["location_id"], "") if row["location_id"] else "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parts_under(conn, table: str, node_id: int) -> list[int]:
|
||||
"""Ids of every part filed at a node or anywhere beneath it."""
|
||||
column = "category_id" if table == "categories" else "location_id"
|
||||
ids = descendants(conn, table, node_id)
|
||||
marks = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id FROM parts WHERE {column} IN ({marks})", ids
|
||||
).fetchall()
|
||||
return [r["id"] for r in rows]
|
||||
|
||||
|
||||
def reindex_parts(conn, part_ids):
|
||||
"""Reindex a batch, resolving the path tables only once."""
|
||||
part_ids = list(part_ids)
|
||||
if not part_ids:
|
||||
return
|
||||
cat_paths = tree_paths(conn, "categories")
|
||||
loc_paths = tree_paths(conn, "locations")
|
||||
for part_id in part_ids:
|
||||
reindex_part(conn, part_id, cat_paths, loc_paths)
|
||||
|
||||
|
||||
def reindex_all(conn):
|
||||
conn.execute("DELETE FROM parts_fts")
|
||||
for (pid,) in conn.execute("SELECT id FROM parts").fetchall():
|
||||
reindex_part(conn, pid)
|
||||
reindex_parts(conn, [r[0] for r in conn.execute("SELECT id FROM parts").fetchall()])
|
||||
|
||||
|
||||
def fts_query(text: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user