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:
parent
410afe0078
commit
62960d5bea
2 changed files with 47 additions and 1 deletions
|
|
@ -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…</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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue