From 62960d5beac7cd8a590d8e9c2a2415a567323a1b Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Tue, 26 May 2026 19:14:17 +0200 Subject: [PATCH] security: stop localStorage leaking portfolios across users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: the per-browser pie was stored under a single global key (`cassandra.pie`) with no per-user scope. If User A uploaded a portfolio and User B then signed in on the same browser, User B saw User A's holdings — portfolio.js read straight from localStorage on hydration with no check that the data belonged to the current session. This was not a server-side leak: the session cookie was correct, no API returned User A's data to User B. The stale browser state was the sole vector. Reported by the operator while testing the paid-checkout flow with a second account on the same browser. Fix — defense in depth, two layers: 1. base.html now stamps cu.user.id into localStorage as `cassandra.user_id` on every authenticated page load. If the previous stamp doesn't match the current user, wipe localStorage (preserving only `cassandra.theme`, which is cosmetic) and sessionStorage before any other script runs. This catches: - the reported scenario (User A logs out, User B logs in) - any case where logout missed the wipe (JS disabled, browser killed before the redirect ran) - cookie-revocation / session-rotation edge cases where the server-side identity changes without an explicit logout 2. /logout no longer returns a bare 303; it returns a small HTML page that actively wipes per-user localStorage + sessionStorage client-side (theme preserved), then redirects to /login. A meta-refresh covers the no-JS case (the cookie deletion is still server-side, so security is preserved either way). Behaviour for the legitimate case (same user logs out + back in) is unchanged: their localStorage data survives because the mismatch check sees the same user_id and doesn't fire — the logout wipe runs but they re-stamp + re-upload only the cassandra.user_id and a fresh pie cycle if they choose to upload. Suite: 221 passed, 5 skipped, 0 failed. Co-Authored-By: Claude Opus 4.7 --- app/routers/auth.py | 25 ++++++++++++++++++++++++- app/templates/base.html | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/routers/auth.py b/app/routers/auth.py index 05e3de1..e567aa4 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -284,9 +284,32 @@ async def verify_resend( # --------------------------------------------------------------------------- +_LOGOUT_HTML = """ + +Signing out… + + +Signing out…""" + + @router.post("/logout") async def logout(request: Request): - resp = RedirectResponse(url="/login", status_code=303) + resp = HTMLResponse(content=_LOGOUT_HTML) resp.delete_cookie(SESSION_COOKIE_NAME, path="/") _clear_pending_cookie(resp) return resp diff --git a/app/templates/base.html b/app/templates/base.html index aec5c88..9fdb0d1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,6 +4,29 @@ {% block title %}{{ BRAND_NAME }}{% endblock %} + {# Cross-user contamination guard. + + localStorage is browser-wide; if User A uploads a portfolio and User B + logs in on the same browser, the stale `cassandra.pie` would otherwise + render as User B's holdings. We stamp the logged-in user's id in + localStorage on every authenticated page load and wipe per-user keys + if the id changed since last time. Theme stays — it's cosmetic. #} + {# Apply saved theme before stylesheet renders to avoid a flash. #}