diff --git a/frontend/src/stores/__tests__/auth.spec.ts b/frontend/src/stores/__tests__/auth.spec.ts index 39bff8e97..54408e250 100644 --- a/frontend/src/stores/__tests__/auth.spec.ts +++ b/frontend/src/stores/__tests__/auth.spec.ts @@ -100,6 +100,51 @@ describe('auth store logout', () => { expect(clearAuthMock).toHaveBeenCalledWith(false, false) }) + it.each(['synchronous', 'asynchronous'] as const)( + 'allows a forced retry after a %s restore failure', + async (failureMode) => { + const failure = new Error('temporary refresh failure') + if (failureMode === 'synchronous') { + restoreSessionMock.mockImplementationOnce(() => { + throw failure + }) + } else { + restoreSessionMock.mockRejectedValueOnce(failure) + } + restoreSessionMock.mockResolvedValueOnce('retry-access-token') + const store = useAuthStore() + + await expect(store.restoreSession()).resolves.toBe(false) + expect(clearAuthMock).toHaveBeenCalledWith(false, false) + + await expect(store.restoreSession(true)).resolves.toBe(true) + expect(store.token).toBe('retry-access-token') + expect(restoreSessionMock).toHaveBeenCalledTimes(2) + }, + ) + + it('deduplicates in-flight restores and allows a refresh after completion', async () => { + let resolveRestore!: (token: string) => void + const pendingRestore = new Promise((resolve) => { + resolveRestore = resolve + }) + restoreSessionMock.mockReturnValueOnce(pendingRestore) + const store = useAuthStore() + + const firstRestore = store.restoreSession() + const secondRestore = store.restoreSession() + expect(restoreSessionMock).toHaveBeenCalledTimes(1) + + resolveRestore('restored-access-token') + await expect(Promise.all([firstRestore, secondRestore])).resolves.toEqual([true, true]) + expect(store.token).toBe('restored-access-token') + + restoreSessionMock.mockResolvedValueOnce('refreshed-access-token') + await expect(store.restoreSession(true)).resolves.toBe(true) + expect(store.token).toBe('refreshed-access-token') + expect(restoreSessionMock).toHaveBeenCalledTimes(2) + }) + it('preserves an existing access token when a forced restore fails', async () => { getTokenMock.mockReturnValue('still-valid-access-token') restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict')) diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e083cb988..62c454415 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -81,12 +81,12 @@ export const useAuthStore = defineStore('auth', () => { } } return false - } finally { - if (sessionRestorePromise === request) { - sessionRestorePromise = null - } } - })() + })().finally(() => { + if (sessionRestorePromise === request) { + sessionRestorePromise = null + } + }) sessionRestorePromise = request return request diff --git a/frontend/src/utils/__tests__/navigationSecurity.spec.ts b/frontend/src/utils/__tests__/navigationSecurity.spec.ts index 41310cdef..3b35c057b 100644 --- a/frontend/src/utils/__tests__/navigationSecurity.spec.ts +++ b/frontend/src/utils/__tests__/navigationSecurity.spec.ts @@ -6,6 +6,12 @@ import { safeInternalNavigationPath, } from '../navigationSecurity' +const unicodeSegments = ['👩\u200d💻', 'راهنمای\u200cکاربر', '\ue000'] +const controlCodePoints = [ + ...Array.from({ length: 32 }, (_value, index) => index), + ...Array.from({ length: 33 }, (_value, index) => index + 0x7f), +] + describe('safeInternalNavigationPath', () => { it('keeps normalized same-origin paths, queries, and fragments', () => { expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe( @@ -13,6 +19,17 @@ describe('safeInternalNavigationPath', () => { ) }) + it.each(unicodeSegments)('keeps valid Unicode query values: %s', (segment) => { + expect(safeInternalNavigationPath(`/dashboard?search=${segment}`)).toBe( + `/dashboard?search=${encodeURIComponent(segment)}`, + ) + }) + + it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => { + expect(safeInternalNavigationPath(`/dashboard/before${String.fromCodePoint(codePoint)}after`)) + .toBeNull() + }) + it.each([ 'https://attacker.example/steal', '//attacker.example/steal', @@ -32,6 +49,17 @@ describe('safeExternalHttpsUrl', () => { ) }) + it.each(unicodeSegments)('allows valid Unicode URL paths: %s', (segment) => { + expect(safeExternalHttpsUrl(`https://provider.example/docs/${segment}`)).toBe( + `https://provider.example/docs/${encodeURIComponent(segment)}`, + ) + }) + + it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => { + expect(safeExternalHttpsUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`)) + .toBeNull() + }) + it.each([ 'javascript:alert(1)', 'data:text/html,attack', @@ -53,6 +81,17 @@ describe('safeExternalWebUrl', () => { expect(safeExternalWebUrl(value)).toBe(expected) }) + it.each(unicodeSegments)('allows valid Unicode provider websites: %s', (segment) => { + expect(safeExternalWebUrl(`https://provider.example/docs/${segment}`)).toBe( + `https://provider.example/docs/${encodeURIComponent(segment)}`, + ) + }) + + it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => { + expect(safeExternalWebUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`)) + .toBeNull() + }) + it.each([ 'javascript:alert(1)', 'data:text/html,attack', diff --git a/frontend/src/utils/navigationSecurity.ts b/frontend/src/utils/navigationSecurity.ts index eb3d45706..abe8bc066 100644 --- a/frontend/src/utils/navigationSecurity.ts +++ b/frontend/src/utils/navigationSecurity.ts @@ -1,5 +1,5 @@ const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid' -const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{C}]/u +const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{Cc}]/u function safeAbsoluteExternalUrl( value: string | null | undefined, @@ -38,7 +38,7 @@ export function safeInternalNavigationPath(value: string | null | undefined): st !candidate.startsWith('/') || candidate.startsWith('//') || candidate.includes('\\') - || /\p{C}/u.test(candidate) + || /\p{Cc}/u.test(candidate) ) { return null }