7bdf276342
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>
159 lines
5.1 KiB
Python
159 lines
5.1 KiB
Python
"""Single-user login gate.
|
|
|
|
The app sits on a public hostname, so it needs a door. One shared password from
|
|
the environment buys a signed, expiring cookie — no user table, no password
|
|
reset flow, nothing to administer. Set PARTS_AUTH=off for LAN-only use.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import Cookie, HTTPException, Request
|
|
|
|
COOKIE_NAME = "parts_session"
|
|
DIGEST_SIZE = hashlib.sha256().digest_size
|
|
|
|
|
|
def _env(name: str, default: str = "") -> str:
|
|
return os.environ.get(name, default).strip()
|
|
|
|
|
|
def _env_int(name: str, default: int, minimum: int = 1) -> int:
|
|
try:
|
|
return max(minimum, int(_env(name, str(default))))
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
def auth_enabled() -> bool:
|
|
return _env("PARTS_AUTH", "on").lower() not in ("off", "0", "false", "no")
|
|
|
|
|
|
def _secret() -> bytes:
|
|
secret = _env("PARTS_SECRET")
|
|
if not secret:
|
|
# Without a configured secret, derive one from the password so sessions
|
|
# are still unforgeable — they just don't survive a password change.
|
|
secret = "fallback:" + _env("PARTS_PASSWORD")
|
|
return hashlib.sha256(secret.encode()).digest()
|
|
|
|
|
|
def session_days() -> int:
|
|
return _env_int("PARTS_SESSION_DAYS", 30)
|
|
|
|
|
|
def check_password(candidate: str) -> bool:
|
|
expected = _env("PARTS_PASSWORD")
|
|
if not expected:
|
|
return False
|
|
return hmac.compare_digest(candidate.encode(), expected.encode())
|
|
|
|
|
|
# --- session tokens ---------------------------------------------------------
|
|
#
|
|
# The signature is raw HMAC bytes, which can contain any byte value including
|
|
# the one for ".". An earlier version joined payload and signature with "." and
|
|
# split on the last occurrence, so roughly 12% of issued tokens (1 - (255/256)^32)
|
|
# split in the wrong place and failed their own validator. The digest is a fixed
|
|
# 32 bytes, so slice by length instead of looking for a delimiter.
|
|
|
|
def issue_token() -> str:
|
|
expires = int(time.time()) + session_days() * 86400
|
|
payload = str(expires).encode()
|
|
sig = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
|
return base64.urlsafe_b64encode(payload + sig).decode()
|
|
|
|
|
|
def token_valid(token: str) -> bool:
|
|
try:
|
|
raw = base64.urlsafe_b64decode(token.encode())
|
|
if len(raw) <= DIGEST_SIZE:
|
|
return False
|
|
payload, sig = raw[:-DIGEST_SIZE], raw[-DIGEST_SIZE:]
|
|
expected = hmac.new(_secret(), payload, hashlib.sha256).digest()
|
|
if not hmac.compare_digest(sig, expected):
|
|
return False
|
|
return int(payload) > time.time()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# --- failed-login throttling ------------------------------------------------
|
|
#
|
|
# Deliberately a GLOBAL window rather than per-IP. Caddy appends to
|
|
# X-Forwarded-For rather than replacing it, so the client-supplied end of that
|
|
# header is attacker-controlled and a per-IP bucket would be trivially evaded by
|
|
# forging it. There is exactly one legitimate user, so a global cap costs
|
|
# nothing in practice and cannot be side-stepped by rotating source addresses.
|
|
#
|
|
# The trade-off: someone spraying guesses can lock the login form for the
|
|
# cooldown. Existing sessions keep working (they carry a valid cookie), so that
|
|
# is an annoyance rather than a lockout.
|
|
|
|
_failures: list[float] = []
|
|
_failures_lock = threading.Lock()
|
|
|
|
|
|
def _window() -> int:
|
|
return _env_int("PARTS_LOGIN_WINDOW", 300)
|
|
|
|
|
|
def _max_failures() -> int:
|
|
return _env_int("PARTS_LOGIN_MAX_FAILURES", 10)
|
|
|
|
|
|
def login_retry_after() -> int:
|
|
"""Seconds until the next login attempt is allowed, or 0 if allowed now."""
|
|
now = time.time()
|
|
window = _window()
|
|
with _failures_lock:
|
|
recent = [t for t in _failures if now - t < window]
|
|
_failures[:] = recent
|
|
if len(recent) < _max_failures():
|
|
return 0
|
|
return max(1, int(window - (now - recent[0])))
|
|
|
|
|
|
def record_failure():
|
|
with _failures_lock:
|
|
_failures.append(time.time())
|
|
|
|
|
|
def clear_failures():
|
|
with _failures_lock:
|
|
_failures.clear()
|
|
|
|
|
|
# --- request helpers --------------------------------------------------------
|
|
|
|
def is_https(request: Request) -> bool:
|
|
"""Whether the *original* client request used TLS.
|
|
|
|
Caddy terminates TLS and proxies to this app over plain HTTP, so
|
|
`request.url.scheme` is always "http" in production and can't decide whether
|
|
the session cookie may carry the Secure flag. X-Forwarded-Proto is what the
|
|
proxy records the real scheme in.
|
|
"""
|
|
override = _env("PARTS_SECURE_COOKIE", "auto").lower()
|
|
if override in ("on", "1", "true", "yes"):
|
|
return True
|
|
if override in ("off", "0", "false", "no"):
|
|
return False
|
|
forwarded = request.headers.get("x-forwarded-proto", "")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip().lower() == "https"
|
|
return request.url.scheme == "https"
|
|
|
|
|
|
def require_auth(parts_session: str | None = Cookie(default=None)):
|
|
"""FastAPI dependency guarding every data route."""
|
|
if not auth_enabled():
|
|
return True
|
|
if parts_session and token_valid(parts_session):
|
|
return True
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|