Skip to content

feat: per-browser session isolation via X-Client-Id (fixes shared-state privacy leak)#17

Merged
AnkanMisra merged 1 commit intomainfrom
feat/per-client-isolation
May 3, 2026
Merged

feat: per-browser session isolation via X-Client-Id (fixes shared-state privacy leak)#17
AnkanMisra merged 1 commit intomainfrom
feat/per-client-isolation

Conversation

@AnkanMisra
Copy link
Copy Markdown
Owner

@AnkanMisra AnkanMisra commented May 3, 2026

Why

User report: opened the deployed chainshield.pages.dev in two browsers, the second browser showed the first browser's policies and decisions. The deployed server has one in-memory store and every browser hitting the same URL was reading the same data — privacy violation for any non-trivial deployment.

What changes

A stable per-browser UUID is generated on first load, sent as the X-Client-Id header on every API call, and used as a filter on every store read + a tag on every store write. Browser A's clientId never sees Browser B's data; foreign id lookups return 404 instead of leaking existence.

Frontend — one stable session id per browser

web/src/lib/api.ts now exports getClientId():

  • Reads from localStorage under key chainshield:client-id
  • Generates a fresh crypto.randomUUID() on first load and persists it
  • Falls back to a per-tab in-memory id if localStorage throws (Safari private mode)
  • The api() wrapper sets X-Client-Id on every fetch automatically — no caller-side change needed

Server — clientId threads through the whole request

  • Store interface: every CRUD method takes an optional clientId. Writes tag the row; reads filter to matching tag. undefined clientId = global admin view (legacy curl/CLI path).
  • InMemoryStore, ZeroGStore: store rows in Map<id, { row, clientId }> shape. Filter on read.
  • PolicyService.create / update / get / list: thread clientId.
  • DecisionEngine.evaluate(intent, policy, clientId?): persists the decision under the caller's clientId, and the daily-outflow timeline read uses the same id so Browser A's history doesn't bleed into Browser B's maxDailyOutflowEth rule.
  • Fastify clientIdOf(req): reads x-client-id, trims, validates (1-128 chars), returns undefined on missing/oversized to fall back to admin view safely.

Tests — 8 new specs

tests/clientIsolation.test.ts:

  • Browser A's policy invisible to Browser B's /policies list
  • GET /policies/:id returns 404 for a foreign-owned id (no existence leak)
  • POST /evaluate returns 404 (PolicyNotFound) for a foreign-owned policy
  • /timeline is isolated — Browser A's decisions don't appear in Browser B's view
  • Requests without X-Client-Id see the global admin view (curl / CLI / tests)
  • Oversized header (>128 chars) treated as missing, falls back to admin view

Plus two store-level unit tests verifying the tagging behaviour directly.

Verification

  • bun test101 specs across 12 files (was 93; +8 isolation specs)
  • bun run typecheck — server (tsc --noEmit) + web (astro check: 0/0/0) clean
  • bun run build:web — Astro production build succeeds
  • Emoji-bytes scan — zero hits
  • Manual smoke after merge + redeploy: open the deployed Pages URL in two different browsers, create a policy in browser A, confirm it doesn't appear in browser B's policy list. Browser B's clientId is fresh, sees an empty workspace.

Backwards compatibility

  • The CLI demo (bun run demo) doesn't send the header — falls into the admin view, sees everything as before.
  • Existing tests that don't pass clientId still pass — undefined everywhere = legacy global behaviour.
  • The Policy and Decision JSON schemas are unchanged — clientId tagging lives entirely inside the Store implementation, not on the wire.

What this PR does NOT do

  • It doesn't add any auth (no signing, no claims). The clientId is anonymous and trusted on its face — anyone forging a UUID gets a fresh workspace, can't impersonate another's. Sufficient for the hackathon scope.
  • It doesn't persist the clientId across browsers. Logging in from a fresh browser starts a new workspace by design.
  • It doesn't change the Quick Demo button loading state — that's a separate concern in PR feat(web): show busy state on Quick Demo + Refresh buttons during async actions #16.

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 3, 2026

Warning

Rate limit exceeded

@AnkanMisra has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 54 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ffbeed30-6e3f-4dd1-8bf8-f996f7f798a9

📥 Commits

Reviewing files that changed from the base of the PR and between 7113c59 and 83fdbcb.

📒 Files selected for processing (9)
  • src/core/engine.ts
  • src/core/policyService.ts
  • src/memory/memoryStore.ts
  • src/memory/store.ts
  • src/memory/zeroGStore.ts
  • src/risk-gate/app.ts
  • tests/clientIsolation.test.ts
  • tests/zeroGStore.test.ts
  • web/src/lib/api.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/per-client-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 0/1 reviews remaining, refill in 36 minutes and 54 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 3, 2026

Deploying chainshield with  Cloudflare Pages  Cloudflare Pages

Latest commit: 83fdbcb
Status: ✅  Deploy successful!
Preview URL: https://f35e8191.chainshield.pages.dev
Branch Preview URL: https://feat-per-client-isolation.chainshield.pages.dev

View logs

…onger leaks one user's policies and timeline rows to other users; the server has one in-memory store and prior to this every browser hitting the same URL saw the same /policies and /timeline; the fix tags every row at write time with the X-Client-Id header sent by the frontend (a stable UUID stored in localStorage on first load) and filters every read by the same id, so Browser A's clientId=abc only sees rows it wrote and getPolicy on a foreign id returns 404 without leaking existence; the Store interface now accepts an optional clientId on every CRUD method, InMemoryStore + ZeroGStore tag rows internally without changing the public Policy/Decision schema, PolicyService and DecisionEngine thread the id end-to-end (the daily-outflow rule reads the timeline for its own clientId so Browser A's transactions don't bleed into Browser B's daily cap), Fastify clientIdOf reads x-client-id with bound checks (1-128 chars, trimmed, falls back to admin view on missing/oversized), and the Astro fetch wrapper persists getClientId() in localStorage falling back to in-memory uuid when localStorage throws (Safari private mode); 8 new specs cover the API path (browser A's policy invisible to B, getPolicy 404 not 200-empty for foreign id, evaluate 404s for foreign policy, timeline isolation, admin view via no-header, oversized header rejection) plus two store-level unit tests
@AnkanMisra AnkanMisra force-pushed the feat/per-client-isolation branch from 49c96f1 to 83fdbcb Compare May 3, 2026 12:33
@AnkanMisra AnkanMisra merged commit 9ea88bc into main May 3, 2026
3 checks passed
@AnkanMisra AnkanMisra deleted the feat/per-client-isolation branch May 3, 2026 12:34
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