security: stop localStorage leaking portfolios across users

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 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-26 19:14:17 +02:00
parent 410afe0078
commit 62960d5bea
2 changed files with 47 additions and 1 deletions

View file

@ -284,9 +284,32 @@ async def verify_resend(
# ---------------------------------------------------------------------------
_LOGOUT_HTML = """<!doctype html><html lang="en"><head>
<meta charset="utf-8">
<title>Signing out</title>
<meta http-equiv="refresh" content="0;url=/login">
<script>
// Wipe per-user browser state before the redirect. Keeps `cassandra.theme`
// (cosmetic, no privacy concern) so the next user's first paint isn't a
// white-flash. The meta-refresh above is the no-JS fallback for the redirect;
// without JS, localStorage isn't cleared, but base.html's user-mismatch
// guard catches the next authenticated page load.
(function() {
try {
var theme = localStorage.getItem('cassandra.theme');
localStorage.clear();
if (theme) localStorage.setItem('cassandra.theme', theme);
sessionStorage.clear();
} catch (e) {}
window.location.replace('/login');
})();
</script>
</head><body>Signing out&hellip;</body></html>"""
@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

View file

@ -4,6 +4,29 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
{# 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. #}
<script>
(function() {
try {
var current = "{{ cu.user.id if cu and cu.user else '' }}";
if (!current) return;
var last = localStorage.getItem('cassandra.user_id') || '';
if (last && last !== current) {
var theme = localStorage.getItem('cassandra.theme');
localStorage.clear();
if (theme) localStorage.setItem('cassandra.theme', theme);
try { sessionStorage.clear(); } catch (e) {}
}
localStorage.setItem('cassandra.user_id', current);
} catch (e) {}
})();
</script>
{# Apply saved theme before stylesheet renders to avoid a flash. #}
<script>
(function() {