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:
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { clearAll } from '../db/offlineDb'
|
||||
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
|
||||
import { setAuthed } from '../sync/authGate'
|
||||
import { unregisterSyncTriggers } from '../sync/syncTriggers'
|
||||
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
||||
|
||||
interface AuthResponse {
|
||||
@@ -40,7 +42,7 @@ interface AuthState {
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
@@ -65,6 +67,19 @@ interface AuthState {
|
||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||
let authSequence = 0
|
||||
|
||||
/**
|
||||
* Mark the session authenticated and point the offline DB at this user's scoped
|
||||
* database before any background sync runs, so cached data never crosses users.
|
||||
*/
|
||||
async function onAuthSuccess(userId: number): Promise<void> {
|
||||
setAuthed(true)
|
||||
try {
|
||||
await reopenForUser(userId)
|
||||
} catch (err) {
|
||||
console.error('[auth] failed to open user-scoped offline DB', err)
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -99,6 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -123,6 +139,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -147,6 +164,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
@@ -158,18 +176,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
|
||||
setAuthed(false)
|
||||
set({ isAuthenticated: false })
|
||||
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
|
||||
unregisterSyncTriggers()
|
||||
// 3. Tear down the live connection.
|
||||
disconnect()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
// 4. Tell server to clear the httpOnly cookie (best-effort).
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// 5. Clear service worker caches containing sensitive data.
|
||||
if ('caches' in window) {
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
await Promise.all([
|
||||
caches.delete('api-data').catch(() => {}),
|
||||
caches.delete('user-uploads').catch(() => {}),
|
||||
])
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
|
||||
await deleteCurrentUserDb().catch(console.error)
|
||||
// 7. Finish clearing auth state.
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -189,6 +216,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
@@ -282,6 +310,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
demoMode: true,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Auth gate — a single boolean the sync layer checks before touching the
|
||||
* offline DB. It lets logout disable all background sync (flush / syncAll /
|
||||
* periodic triggers) *before* awaiting the DB swap, so an in-flight loop can't
|
||||
* re-seed the database after the user has logged out.
|
||||
*
|
||||
* Kept separate from authStore to avoid an import cycle
|
||||
* (authStore → tripSyncManager → authStore).
|
||||
*/
|
||||
let _authed = false
|
||||
|
||||
export function setAuthed(value: boolean): void {
|
||||
_authed = value
|
||||
}
|
||||
|
||||
export function isAuthed(): boolean {
|
||||
return _authed
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import { isAuthed } from './authGate'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
@@ -88,7 +89,7 @@ export const mutationQueue = {
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine) return
|
||||
if (_flushing || !navigator.onLine || !isAuthed()) return
|
||||
_flushing = true
|
||||
// tempId → realId learned during this flush, so a dependent edit/delete
|
||||
// queued against an offline-created entity (still holding the negative id)
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
clearTripData,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||
|
||||
@@ -134,7 +135,7 @@ export const tripSyncManager = {
|
||||
* No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
Reference in New Issue
Block a user