fix(frontend): preserve session cleanup and Unicode navigation

This commit is contained in:
elky
2026-09-07 09:57:53 +08:00
parent fc0417ceb9
commit 2f929e74c7
4 changed files with 91 additions and 7 deletions
@@ -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<string>((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'))
+5 -5
View File
@@ -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
@@ -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',
+2 -2
View File
@@ -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
}