mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +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:
@@ -54,6 +54,33 @@ export interface BlobCacheEntry {
|
||||
|
||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The offline DB is scoped per user so that one account can never read another
|
||||
* account's cached data on a shared device. Anonymous (logged-out) state uses
|
||||
* the base name; a logged-in user uses `trek-offline-u<userId>`.
|
||||
*/
|
||||
const ANON_DB_NAME = 'trek-offline';
|
||||
|
||||
function userDbName(userId: number | string): string {
|
||||
return `trek-offline-u${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of the persisted auth snapshot so the very first DB opened on
|
||||
* app load (before loadUser resolves) is already the correct per-user one — the
|
||||
* PWA can render cached data offline without leaking across users.
|
||||
*/
|
||||
function initialDbName(): string {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
|
||||
if (!raw) return ANON_DB_NAME;
|
||||
const id = JSON.parse(raw)?.state?.user?.id;
|
||||
return id != null ? userDbName(id) : ANON_DB_NAME;
|
||||
} catch {
|
||||
return ANON_DB_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
class TrekOfflineDb extends Dexie {
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
@@ -71,8 +98,8 @@ class TrekOfflineDb extends Dexie {
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
|
||||
constructor() {
|
||||
super('trek-offline');
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
|
||||
this.version(1).stores({
|
||||
trips: 'id',
|
||||
@@ -97,7 +124,53 @@ class TrekOfflineDb extends Dexie {
|
||||
}
|
||||
}
|
||||
|
||||
export const offlineDb = new TrekOfflineDb();
|
||||
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
||||
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
|
||||
// import it directly, while every access forwards to the current connection.
|
||||
let _db = new TrekOfflineDb(initialDbName());
|
||||
|
||||
export const offlineDb = new Proxy({} as TrekOfflineDb, {
|
||||
get(_target, prop) {
|
||||
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
||||
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
}) as TrekOfflineDb;
|
||||
|
||||
async function switchTo(name: string): Promise<void> {
|
||||
if (_db.name === name) {
|
||||
if (!_db.isOpen()) await _db.open();
|
||||
return;
|
||||
}
|
||||
if (_db.isOpen()) _db.close();
|
||||
_db = new TrekOfflineDb(name);
|
||||
await _db.open();
|
||||
}
|
||||
|
||||
/** Point the offline DB at a specific user's scoped database (call on login). */
|
||||
export async function reopenForUser(userId: number | string): Promise<void> {
|
||||
await switchTo(userDbName(userId));
|
||||
}
|
||||
|
||||
/** Point the offline DB at the anonymous database (call on logout). */
|
||||
export async function reopenAnonymous(): Promise<void> {
|
||||
await switchTo(ANON_DB_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current user's scoped database entirely and return to the anonymous
|
||||
* DB. Used on logout so no trace of the account's data remains on the device.
|
||||
*/
|
||||
export async function deleteCurrentUserDb(): Promise<void> {
|
||||
if (_db.name !== ANON_DB_NAME) {
|
||||
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
|
||||
}
|
||||
_db = new TrekOfflineDb(ANON_DB_NAME);
|
||||
await _db.open();
|
||||
}
|
||||
|
||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user