From cdb25b1abacfa5e8ded4e9d696b49d8d182a9ca7 Mon Sep 17 00:00:00 2001 From: Josue Zamudio Date: Wed, 19 Aug 2026 16:29:03 +0000 Subject: [PATCH] security: Secure cookie flag (ANIWEB_SECURE, on in run.sh) + kill sessions of deleted accounts - middleware re-verifies the username against users.json per request: deleted accounts' signed cookies are rejected immediately - set_cookie/delete_cookie honor the Secure flag; run.sh sets ANIWEB_SECURE=1 so sessions only work through the HTTPS proxy --- README.md | 6 ++++++ run.sh | 6 ++++-- server/app.py | 11 ++++++++--- tests/test_api.py | 2 ++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index eb78c3b..3c452c6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ Account management (no self-signup by design): .venv/bin/python -m server.users list # list users ``` +Security: passwords are PBKDF2-SHA256 (210k iters, per-user salts) in +`server/data/users.json` (0600). Sessions are signed 30-day `HttpOnly` +`SameSite=Lax` cookies; `run.sh` sets `ANIWEB_SECURE=1` so cookies are +`Secure` and only work through your HTTPS proxy. Accounts deleted via +the app or CLI are rejected immediately, even with an old cookie. + ## Features - Per-user accounts (CLI-created, no self-signup): 30-day signed diff --git a/run.sh b/run.sh index 3eccad4..aead454 100755 --- a/run.sh +++ b/run.sh @@ -3,5 +3,7 @@ cd "$(dirname "$0")" || exit 1 [ -x .venv/bin/python ] || { echo "no .venv — run: uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt"; exit 1; } pkill -f "uvicorn .*app:app" 2>/dev/null; sleep 1 -nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 > server.log 2>&1 & -echo "ani-web on http://localhost:8321 (pid $!)" +# ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS +# proxy (plain http://:8321 can no longer hold a session). +ANIWEB_SECURE=1 nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 > server.log 2>&1 & +echo "ani-web on http://localhost:8321 (pid $!) — use the HTTPS proxy to log in" diff --git a/server/app.py b/server/app.py index 10d01a8..24e775c 100644 --- a/server/app.py +++ b/server/app.py @@ -27,6 +27,7 @@ _SIGNER = TimestampSigner(users.get_secret()) _SESSION = "aniweb_session" _SESSION_AGE = 30 * 86400 _PUBLIC = {("/login", "GET"), ("/login", "POST")} +_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS # ponytail: in-memory lockout per username, single process — 5 fails -> 15 min _lockout: dict = {} @@ -59,6 +60,10 @@ async def require_auth(request, call_next): if (request.url.path, request.method) in _PUBLIC: return await call_next(request) user = _session_user(request) + # stateless cookie -> re-verify the account still exists, so + # deleted users' cookies die immediately + if user is not None and user not in users.load(): + user = None if not user: if request.url.path.startswith("/api/"): return JSONResponse({"detail": "not authenticated"}, status_code=401) @@ -70,7 +75,7 @@ async def require_auth(request, call_next): def _session_cookie(resp, user: str): resp.set_cookie(_SESSION, _SIGNER.sign(b"user:" + user.encode()).decode(), - max_age=_SESSION_AGE, httponly=True, samesite="lax") + max_age=_SESSION_AGE, httponly=True, samesite="lax", secure=_SECURE) @app.get("/login", include_in_schema=False) @@ -102,14 +107,14 @@ def api_me(request: Request): def delete_account(request: Request): users.remove(request.state.user) resp = JSONResponse({"ok": True}) - resp.delete_cookie(_SESSION) + resp.delete_cookie(_SESSION, secure=_SECURE) return resp @app.post("/logout") def logout(): resp = JSONResponse({"ok": True}) - resp.delete_cookie(_SESSION) + resp.delete_cookie(_SESSION, secure=_SECURE) return resp diff --git a/tests/test_api.py b/tests/test_api.py index 8ac9fdb..4cf61a1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -119,6 +119,8 @@ def test_self_delete(base=BASE): jar = login("dave", "davepw12345", base) assert req("POST", "/account/delete", jar=jar, base=base) == 200 assert "dave" not in json.load(open(_users_file())) + # old session cookie is dead immediately (middleware re-checks existence) + assert req("GET", "/api/me", jar=jar, base=base) == 401 jar2 = CJ() assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401