mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix: validate session token update events (#2018)
Co-authored-by: LawyerLyu <georgelyutwitter@gmail.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
LawyerLyu
Steven Enamakel
parent
0257b2e662
commit
db087a7d3e
@@ -75,6 +75,31 @@ export function coreStatePollFailureWarningMessage(failureCount: number): string
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> | 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<string, unknown>;
|
||||
} 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<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,13 @@ function makeSnapshot(overrides: {
|
||||
};
|
||||
}
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const encode = (value: Record<string, unknown>) =>
|
||||
window.btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
|
||||
}
|
||||
|
||||
type CoreStateContextValue = ReturnType<typeof useCoreState>;
|
||||
|
||||
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(
|
||||
<CoreStateProvider>
|
||||
<Consumer />
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<CoreStateProvider>
|
||||
<Consumer />
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<CoreStateProvider>
|
||||
<Consumer />
|
||||
</CoreStateProvider>
|
||||
);
|
||||
|
||||
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([]);
|
||||
|
||||
Reference in New Issue
Block a user