Recover stale authenticated CSRF cookies and distinguish interface fetch failures

This commit is contained in:
archipelago
2026-10-06 01:08:31 -04:00
parent 2fa82e4506
commit 7e11f78eb4
5 changed files with 181 additions and 22 deletions
@@ -36,9 +36,38 @@ describe('RPCClient', () => {
})
afterEach(() => {
document.cookie = 'csrf_token=; Max-Age=0; Path=/'
vi.useRealTimers()
})
it('uses a refreshed CSRF cookie once even with a single-attempt request', async () => {
document.cookie = 'csrf_token=old; Path=/'
mockFetch.mockImplementationOnce(async () => {
document.cookie = 'csrf_token=new; Path=/'
return jsonResponse({ error: { message: 'CSRF token missing or invalid' } }, 403)
}).mockResolvedValueOnce(jsonResponse({ result: { interfaces: [] } }))
await expect(rpcClient.call({ method: 'network.list-interfaces', maxRetries: 1 })).resolves.toEqual({ interfaces: [] })
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch.mock.calls[0]![1].headers['X-CSRF-Token']).toBe('old')
expect(mockFetch.mock.calls[1]![1].headers['X-CSRF-Token']).toBe('new')
})
it('never loops on changing CSRF cookies or retries a permissions rejection', async () => {
let count = 0
mockFetch.mockImplementation(async () => {
document.cookie = `csrf_token=rotated-${++count}; Path=/`
return jsonResponse({ error: { message: 'CSRF token missing or invalid' } }, 403)
})
await expect(rpcClient.call({ method: 'network.list-interfaces', maxRetries: 1 })).rejects.toThrow('CSRF')
expect(mockFetch).toHaveBeenCalledTimes(2)
mockFetch.mockReset().mockImplementationOnce(async () => {
document.cookie = 'csrf_token=another; Path=/'
return jsonResponse({ error: { message: 'Forbidden: insufficient permissions' } }, 403)
})
await expect(rpcClient.call({ method: 'system.settings.set', maxRetries: 1 })).rejects.toThrow('insufficient permissions')
expect(mockFetch).toHaveBeenCalledOnce()
})
it('makes a successful RPC call and returns the result', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { did: 'did:key:z123' } }))
+11
View File
@@ -125,6 +125,7 @@ class RPCClient {
private async callInner<T>(options: RPCOptions): Promise<T> {
const { method, params = {}, timeout = 15000, signal: external } = options
const maxRetries = Math.max(1, options.maxRetries ?? 3)
let csrfRefreshed = false
for (let attempt = 0; attempt < maxRetries; attempt++) {
if (external?.aborted) throw new Error('Aborted')
@@ -187,6 +188,16 @@ class RPCClient {
} catch { /* body parse failed */ }
const isCsrf = !reason || reason.toLowerCase().includes('csrf')
const refreshedToken = getCsrfToken()
if (isCsrf && !csrfRefreshed && refreshedToken && refreshedToken !== csrfToken) {
// The server rejected the action before dispatch and refreshed
// the cookie for this authenticated session. Allow exactly one
// retry even for single-attempt reads; never replay on ambiguous
// network failures or a permissions rejection.
csrfRefreshed = true
attempt--
continue
}
if (isCsrf && attempt < maxRetries - 1) {
// CSRF mismatch — cookie may have been updated by a concurrent
// Set-Cookie response not yet visible to JS. Retry after delay.