diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 37dde8035..ad0578b2e 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -75,6 +75,31 @@ export function coreStatePollFailureWarningMessage(failureCount: number): string return null; } +function decodeJwtPayload(token: string): Record | null { + const [, payload] = token.split('.'); + if (!payload) return null; + + try { + const base64 = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const decoded = window.atob(padded); + return JSON.parse(decoded) as Record; + } catch { + return null; + } +} + +function isPlausibleSessionToken(token: unknown): token is string { + if (typeof token !== 'string') return false; + if (token.trim() !== token || token.length === 0) return false; + if (token.split('.').length !== 3) return false; + + const payload = decodeJwtPayload(token); + if (!payload || typeof payload.exp !== 'number') return false; + + return payload.exp * 1000 > Date.now(); +} + interface CoreStateContextValue extends CoreState { refresh: () => Promise; refreshTeams: () => Promise; @@ -458,7 +483,7 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) const onSessionTokenUpdated = (event: Event) => { const customEvent = event as CustomEvent<{ sessionToken?: string | null }>; const token = customEvent.detail?.sessionToken; - if (typeof token !== 'string' || token.length === 0) { + if (!isPlausibleSessionToken(token)) { return; } diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 515f156db..26da31e29 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -45,6 +45,13 @@ function makeSnapshot(overrides: { }; } +function makeJwt(payload: Record): string { + const encode = (value: Record) => + window.btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + type CoreStateContextValue = ReturnType; function Consumer({ captureCtx }: { captureCtx?: (ctx: CoreStateContextValue) => void }) { @@ -297,6 +304,81 @@ describe('CoreStateProvider — identity-change cache clearing', () => { ); }); + it('ignores malformed session-token-updated events (#1937)', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: null, sessionToken: null })); + listTeams.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-state:session-token-updated', { + detail: { sessionToken: 'not-a-jwt' }, + }) + ); + }); + + expect(screen.getByTestId('token').textContent).toBe('none'); + expect(fetchSnapshot).toHaveBeenCalledTimes(1); + }); + + it('ignores expired JWT-shaped session-token-updated events (#1937)', async () => { + const expiredToken = makeJwt({ exp: Math.floor(Date.now() / 1000) - 60 }); + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: null, sessionToken: null })); + listTeams.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-state:session-token-updated', { + detail: { sessionToken: expiredToken }, + }) + ); + }); + + expect(screen.getByTestId('token').textContent).toBe('none'); + expect(fetchSnapshot).toHaveBeenCalledTimes(1); + }); + + it('accepts unexpired JWT-shaped session-token-updated events (#1937)', async () => { + const token = makeJwt({ exp: Math.floor(Date.now() / 1000) + 60 }); + fetchSnapshot + .mockResolvedValueOnce(makeSnapshot({ userId: null, sessionToken: null })) + .mockResolvedValueOnce( + makeSnapshot({ userId: null, sessionToken: token, isAuthenticated: true }) + ); + listTeams.mockResolvedValue([]); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-state:session-token-updated', { detail: { sessionToken: token } }) + ); + }); + + expect(screen.getByTestId('token').textContent).toBe(token); + }); + it('setMeetAutoOrchestratorHandoff(true) calls update RPC + flips snapshot optimistically (#1299)', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); listTeams.mockResolvedValue([]);