mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(security): stop cross-user offline data leak on shared devices (#1176)
Closes BLOCKER B4 — three reinforcing paths could serve one account's cached data to the next user on a shared device: - The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only (cookie-blind). Changed to NetworkOnly; offline reads come from the per-user IndexedDB cache via the repo layer instead. - IndexedDB had no per-user scoping. The Dexie connection is now scoped per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a stable binding; login opens the user DB, logout deletes it and returns to the anonymous DB. - logout() was fire-and-forget and racy: background flush/syncAll could re-seed the DB after the wipe. It is now async and ordered — close an auth gate, unregister sync triggers, disconnect, clear caches, delete the user DB — and flush()/syncAll() bail when the gate is closed.
This commit is contained in:
@@ -23,6 +23,9 @@ import {
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
reopenForUser,
|
||||
reopenAnonymous,
|
||||
deleteCurrentUserDb,
|
||||
type QueuedMutation,
|
||||
type SyncMeta,
|
||||
type BlobCacheEntry,
|
||||
@@ -271,3 +274,37 @@ describe('offlineDb — clearAll', () => {
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — per-user scoping (B4)', () => {
|
||||
afterEach(async () => {
|
||||
// Leave the suite on the anonymous DB so other tests are unaffected.
|
||||
await reopenAnonymous();
|
||||
});
|
||||
|
||||
it('isolates one user\'s cached data from another', async () => {
|
||||
await reopenForUser(1);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
expect(await offlineDb.places.count()).toBe(1);
|
||||
|
||||
// Switching users must not expose user 1's rows.
|
||||
await reopenForUser(2);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
|
||||
// Switching back restores user 1's data (different physical DB).
|
||||
await reopenForUser(1);
|
||||
expect(await offlineDb.places.get(10)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deleteCurrentUserDb wipes the user DB and returns to anonymous', async () => {
|
||||
await reopenForUser(5);
|
||||
await upsertPlaces([makePlace(20, 1)]);
|
||||
|
||||
await deleteCurrentUserDb();
|
||||
// Now on the anonymous DB — no user data.
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
|
||||
// Re-opening user 5 starts empty (DB was deleted, not just detached).
|
||||
await reopenForUser(5);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,10 +105,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
describe('FE-AUTH-006: logout', () => {
|
||||
it('calls disconnect() and clears user state', () => {
|
||||
it('calls disconnect() and clears user state', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
await useAuthStore.getState().logout();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
@@ -441,10 +441,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
|
||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', () => {
|
||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../../../src/sync/mutationQueue';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { placeRepo } from '../../../src/repo/placeRepo';
|
||||
@@ -16,11 +17,13 @@ import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
mutationQueue._resetFlushing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -215,6 +218,25 @@ describe('mutationQueue.flush — offline guard', () => {
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('does nothing when logged out (auth gate closed)', async () => {
|
||||
setAuthed(false);
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(called).toBe(false);
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pending / pendingCount ────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
@@ -45,6 +46,7 @@ function makeBundle(tripId: number) {
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
@@ -56,6 +58,19 @@ beforeEach(async () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
describe('tripSyncManager.syncAll — auth gate (B4)', () => {
|
||||
it('no-ops when logged out (gate closed)', async () => {
|
||||
setAuthed(false);
|
||||
let called = false;
|
||||
server.use(
|
||||
http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }); }),
|
||||
);
|
||||
await tripSyncManager.syncAll();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user