Skip to content

Namespace cost-strip storage per user — fix cross-user leakage - #53

Merged
aakri0 merged 1 commit into
mainfrom
fix/cost-storage-per-user
May 3, 2026
Merged

Namespace cost-strip storage per user — fix cross-user leakage#53
aakri0 merged 1 commit into
mainfrom
fix/cost-storage-per-user

Conversation

@aakri0

@aakri0 aakri0 commented May 3, 2026

Copy link
Copy Markdown
Owner

The leak

The cost strip persisted last-rendered values under two global `localStorage` keys (`scp.cost.lastEstimate` and `scp.cost.lastActual`). When user A logged out and user B logged in on the same browser, `restorePersistedCostStrip` read those global keys on B's first paint and showed A's Estimated max and Actual cost. Same story across tabs and browser restarts.

Three layers, so this can't recur

(1) Keys are now per-user.
`lastEstimateKey()` and `lastActualKey()` return `scp.cost.lastEstimate:` and `scp.cost.lastActual:`. Every reader/writer was switched: `renderEstimate`, `renderActualCost`, `hydrateCostStripFromJobs`, `restorePersistedCostStrip`, `clearPersistedCostStrip`.

(2) Logout wipes everything.
`doLogout` now calls `clearAllPersistedCostStrip()` which scans `localStorage` and removes every key starting with `scp.cost.lastEstimate:` or `scp.cost.lastActual:`, plus the legacy un-namespaced keys an older build might have left behind. It also resets the on-screen strip cells and clears in-memory state (`pricingRates`, `activePollJobId`, `lastInvokeAt`) so nothing carries over visually.

(3) Defense at boot.
`bootstrapPrincipal` records the principal name in `scp.cost.lastPrincipal`. On every boot, if the just-fetched principal differs from what was last seen on this browser, every cost bucket is wiped before `restorePersistedCostStrip` has a chance to read. Catches the case where a previous logout didn't run (tab closed, JWT manually nuked, etc).

Test plan

  • `pytest -q` — 179 passed (no API change; all UI).
  • Bind-mounted `app.js?v=24` contains 17 references to the new helpers.
  • Manual: log in as admin → run a job → log out → log in as a non-admin → strip starts at "—" (verified locally).

The strip was persisted under two global localStorage keys
(scp.cost.lastEstimate / .lastActual). When user A logged out and user
B logged in on the same browser, restorePersistedCostStrip read those
global keys on user B's first paint and showed user A's last estimate
and actual cost. Same story across browser tabs / restarts.

Three layers so this can't recur:

(1) Keys are now per-user. lastEstimateKey() and lastActualKey() return
    "scp.cost.lastEstimate:<username>" and ".lastActual:<username>".
    Every read/write site (renderEstimate, renderActualCost, hydrate,
    restore, clearPersisted) was switched over.

(2) doLogout wipes EVERY scp.cost.* bucket — every user's, plus the
    legacy un-namespaced keys an older build may have left behind. Also
    resets the on-screen strip + the in-memory pricing/poll state.

(3) bootstrapPrincipal compares the just-fetched principal name against
    the last principal this browser saw (scp.cost.lastPrincipal). If
    they differ — even when the previous logout didn't run for whatever
    reason — every cost bucket is wiped before restorePersistedCostStrip
    has a chance to read.

Bumped app.js?v=24.
@aakri0
aakri0 merged commit 6d31fe9 into main May 3, 2026
3 checks passed
@aakri0
aakri0 deleted the fix/cost-storage-per-user branch May 3, 2026 08:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant