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")
|
@router.post("/logout")
|
||||||
async def logout(request: Request):
|
async def logout(request: Request):
|
||||||
resp = RedirectResponse(url="/login", status_code=303)
|
resp = HTMLResponse(content=_LOGOUT_HTML)
|
||||||
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||||
_clear_pending_cookie(resp)
|
_clear_pending_cookie(resp)
|
||||||
return resp
|
return resp
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,29 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
|
<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. #}
|
{# Apply saved theme before stylesheet renders to avoid a flash. #}
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue