fix(web/security): drop legacy localStorage init in TenantContext

The tenantId useState initializer used to seed itself from localStorage,
opening a small mount-window where a child component could fetch with a
tampered tenantId before the auth effect overrode it from user.tenantId.
No remaining feature reads from this localStorage key (the only
setTenantId call sites are inside TenantContext itself), so the read +
the corresponding write are dead. Initialize empty, clear any stale LS
value once on mount, and rely solely on the JWT-derived user.tenantId.

Backend MUST still derive tenantId from the JWT claim, not from
query/body params - this change is defense-in-depth, not the primary
guarantee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-08 13:30:14 +02:00
parent f620b884c6
commit 592788be99
@@ -10,19 +10,34 @@ interface TenantContextType {
const TenantContext = createContext<TenantContextType | undefined>(undefined);
const LEGACY_FAKE_TENANT_ID = 'deadbeef-dead-beef-dead-beefdeadbeef';
// Legacy localStorage key — older builds wrote the active tenantId here so it
// survived reloads. The current model treats the JWT `/api/auth/me` response as
// the single source of truth, so we never read from this key anymore. We still
// proactively clear it on mount to remove a stale value that an XSS payload or
// a curious user could otherwise plant — this closes a small mount-window risk
// where a child component could fetch with a tampered tenantId before the auth
// effect overrides it. The defense is belt-and-braces: the backend MUST also
// derive tenantId from the JWT, never from query/body params.
const LEGACY_TENANT_ID_LS_KEY = 'tenantId';
export function TenantProvider({ children }: { children: React.ReactNode }) {
const { user, isAuthenticated, isLoading } = useAuth();
const [tenantId, setTenantId] = useState<string>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('tenantId');
// Migrate away from the old fake placeholder ID
return (stored && stored !== LEGACY_FAKE_TENANT_ID) ? stored : '';
// Always start empty. Consumers across the app already gate fetches on
// `if (!tenantId) return;` so the empty initial render is the expected path.
const [tenantId, setTenantId] = useState<string>('');
// One-shot cleanup of any stale localStorage value left over from older builds.
useEffect(() => {
if (typeof window === 'undefined') return;
try {
if (localStorage.getItem(LEGACY_TENANT_ID_LS_KEY) !== null) {
localStorage.removeItem(LEGACY_TENANT_ID_LS_KEY);
}
} catch {
// localStorage may be disabled (privacy mode) — ignore.
}
return '';
});
}, []);
// Update tenant ID from authenticated user
useEffect(() => {
@@ -42,13 +57,6 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
}
}, [user, isAuthenticated, isLoading, tenantId]);
// Save tenant ID to localStorage
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('tenantId', tenantId);
}
}, [tenantId]);
return (
<TenantContext.Provider value={{ tenantId, setTenantId }}>
{children}