csrfRecovery.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import type Koa from 'koa'
  3. import { CsrfProtection } from '../../src/server/security'
  4. import { apiRequest, clearApiSecurityContext, onApiUnauthorized, setApiCsrfToken } from '@/api/client'
  5. const refreshPath = '/api/app/bootstrap?csrfOnly=1'
  6. const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
  7. status, headers: { 'content-type': 'application/json' },
  8. })
  9. const invalid = () => response({ code: 'invalid_csrf', message: 'CSRF token is invalid or missing' }, 403)
  10. afterEach(() => { clearApiSecurityContext(); vi.useRealTimers(); vi.unstubAllGlobals() })
  11. describe('long-lived desktop CSRF recovery', () => {
  12. it('renews an expired eight-hour signed cookie and executes the original action exactly once', async () => {
  13. const csrf = new CsrfProtection()
  14. let clock = 1000, cookie = '', expiresAt = 0, executed = 0
  15. const renew = () => {
  16. const headers: Record<string, unknown> = {}
  17. const ctx = { response: { headers }, get: () => clock < expiresAt ? cookie : '',
  18. set: (key: string, value: unknown) => { headers[key.toLowerCase()] = value } } as unknown as Koa.Context
  19. const token = csrf.issue(ctx)
  20. if (headers['set-cookie']) {
  21. const value = (headers['set-cookie'] as string[])[0]!
  22. expect(value).toContain('Max-Age=28800')
  23. cookie = value.split(';')[0]!; expiresAt = clock + 28_800_000
  24. }
  25. return token
  26. }
  27. const old = renew()
  28. setApiCsrfToken(old, 'account-a')
  29. clock += 28_800_001
  30. const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
  31. if (path === refreshPath) return response({ csrfToken: renew(), userId: 'account-a' })
  32. if (!csrf.verify(clock < expiresAt ? cookie : '', new Headers(init?.headers).get('X-CSRF-Token') ?? undefined)) return invalid()
  33. executed += 1
  34. return response({ accepted: true })
  35. })
  36. vi.stubGlobal('fetch', fetchMock)
  37. const body = { requestId: 'original-send-id', content: '只发送一次' }
  38. await expect(apiRequest('/api/app/conversations/session/messages', {
  39. method: 'POST', body, headers: { 'Idempotency-Key': 'original-send-id' },
  40. })).resolves.toEqual({ accepted: true })
  41. expect(executed).toBe(1)
  42. const mutations = fetchMock.mock.calls.filter(([path]) => path !== refreshPath)
  43. expect(mutations).toHaveLength(2)
  44. expect(mutations.map(([, init]) => init?.body)).toEqual([JSON.stringify(body), JSON.stringify(body)])
  45. expect(mutations.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'))).toEqual(['original-send-id', 'original-send-id'])
  46. expect(new Headers(mutations[0]![1]?.headers).get('X-CSRF-Token')).toBe(old)
  47. expect(new Headers(mutations[1]![1]?.headers).get('X-CSRF-Token')).not.toBe(old)
  48. })
  49. it('shares one refresh across concurrent ordinary-chat and Bot requests', async () => {
  50. setApiCsrfToken('expired', 'account-a')
  51. let release!: (value: Response) => void
  52. const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
  53. if (path === refreshPath) return new Promise<Response>(resolve => { release = resolve })
  54. return new Headers(init?.headers).get('X-CSRF-Token') === 'renewed' ? response({ ok: true }) : invalid()
  55. })
  56. vi.stubGlobal('fetch', fetchMock)
  57. const paths = ['/api/realtime/channels/chat/commands', '/api/app/conversations/bot/messages', '/api/app/account/avatar']
  58. const jobs = paths.map(path => apiRequest(path, { method: 'POST', body: { requestId: path } }))
  59. await vi.waitFor(() => expect(fetchMock.mock.calls.filter(([path]) => path === refreshPath)).toHaveLength(1))
  60. release(response({ csrfToken: 'renewed', userId: 'account-a' }))
  61. await Promise.all(jobs)
  62. expect(fetchMock.mock.calls.filter(([path]) => path === refreshPath)).toHaveLength(1)
  63. for (const path of paths) expect(fetchMock.mock.calls.filter(([value]) => value === path)).toHaveLength(2)
  64. })
  65. it('prepares a missing token and accepts a full bootstrap from an older server', async () => {
  66. const fetchMock = vi.fn(async (path: string) => path === refreshPath
  67. ? response({ csrfToken: 'legacy-token', user: { id: 'account-a' }, authRequired: true, profiles: [] })
  68. : response({ ok: true }))
  69. vi.stubGlobal('fetch', fetchMock)
  70. await apiRequest('/api/app/account/avatar', { method: 'PUT', body: {} })
  71. expect(fetchMock.mock.calls.map(([path]) => path)).toEqual([refreshPath, '/api/app/account/avatar'])
  72. })
  73. it.each([['invalid_origin', 403], ['permission_denied', 403], ['upstream_failure', 500]])(
  74. 'does not retry %s failures', async (code, status) => {
  75. setApiCsrfToken('valid', 'account-a')
  76. const fetchMock = vi.fn(async () => response({ code }, Number(status)))
  77. vi.stubGlobal('fetch', fetchMock)
  78. await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code })
  79. expect(fetchMock).toHaveBeenCalledTimes(1)
  80. },
  81. )
  82. it('does not retry network failures with an unknown submission result', async () => {
  83. setApiCsrfToken('valid', 'account-a')
  84. const fetchMock = vi.fn(async () => { throw new TypeError('Connection lost') })
  85. vi.stubGlobal('fetch', fetchMock)
  86. await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
  87. expect(fetchMock).toHaveBeenCalledTimes(1)
  88. })
  89. it('stops after one retry when the refreshed token is still rejected', async () => {
  90. setApiCsrfToken('expired', 'account-a')
  91. const fetchMock = vi.fn(async (path: string) => path === refreshPath
  92. ? response({ csrfToken: 'new-token', userId: 'account-a' }) : invalid())
  93. vi.stubGlobal('fetch', fetchMock)
  94. await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'invalid_csrf' })
  95. expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(['/api/app/action', refreshPath, '/api/app/action'])
  96. })
  97. it('does not replay an old account action after a cookie account change', async () => {
  98. setApiCsrfToken('expired', 'account-a')
  99. const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
  100. const fetchMock = vi.fn(async (path: string) => path === refreshPath
  101. ? response({ csrfToken: 'account-b-token', userId: 'account-b' }) : invalid())
  102. vi.stubGlobal('fetch', fetchMock)
  103. try {
  104. await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'SECURITY_CONTEXT_CHANGED' })
  105. expect(fetchMock.mock.calls.filter(([path]) => path !== refreshPath)).toHaveLength(1)
  106. expect(expired).toHaveBeenCalledTimes(1)
  107. } finally { unsubscribe() }
  108. })
  109. it('discards a refresh completed after local logout', async () => {
  110. setApiCsrfToken('expired', 'account-a')
  111. let release!: (value: Response) => void
  112. const fetchMock = vi.fn(async (path: string) => path === refreshPath
  113. ? new Promise<Response>(resolve => { release = resolve }) : invalid())
  114. vi.stubGlobal('fetch', fetchMock)
  115. const sending = apiRequest('/api/app/action', { method: 'POST', body: {} }).catch(error => error)
  116. await vi.waitFor(() => expect(release).toBeTypeOf('function'))
  117. clearApiSecurityContext(); setApiCsrfToken('account-b-token', 'account-b')
  118. release(response({ csrfToken: 'late-a-token', userId: 'account-a' }))
  119. expect(await sending).toMatchObject({ code: 'SECURITY_CONTEXT_CHANGED' })
  120. expect(fetchMock.mock.calls.filter(([path]) => path !== refreshPath)).toHaveLength(1)
  121. })
  122. it('cancels one waiting request without cancelling another request sharing the refresh', async () => {
  123. setApiCsrfToken('expired', 'account-a')
  124. let release!: (value: Response) => void
  125. const fetchMock = vi.fn(async (path: string, init?: RequestInit) => path === refreshPath
  126. ? new Promise<Response>(resolve => { release = resolve })
  127. : new Headers(init?.headers).get('X-CSRF-Token') === 'renewed' ? response({ ok: true }) : invalid())
  128. vi.stubGlobal('fetch', fetchMock)
  129. const abort = new AbortController()
  130. const cancelled = apiRequest('/api/app/cancelled', { method: 'POST', signal: abort.signal }).catch(error => error)
  131. const retained = apiRequest('/api/app/retained', { method: 'POST' })
  132. await vi.waitFor(() => expect(release).toBeTypeOf('function'))
  133. abort.abort()
  134. expect(await cancelled).toMatchObject({ code: 'REQUEST_ABORTED' })
  135. release(response({ csrfToken: 'renewed', userId: 'account-a' }))
  136. await expect(retained).resolves.toEqual({ ok: true })
  137. expect(fetchMock.mock.calls.filter(([path]) => path === '/api/app/cancelled')).toHaveLength(1)
  138. expect(fetchMock.mock.calls.filter(([path]) => path === '/api/app/retained')).toHaveLength(2)
  139. })
  140. it('does not send an already cancelled action', async () => {
  141. const fetchMock = vi.fn(), abort = new AbortController(); abort.abort()
  142. vi.stubGlobal('fetch', fetchMock)
  143. await expect(apiRequest('/api/app/action', { method: 'POST', signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_ABORTED' })
  144. expect(fetchMock).not.toHaveBeenCalled()
  145. })
  146. it('honors the original request deadline while renewal is pending', async () => {
  147. setApiCsrfToken('expired', 'account-a')
  148. let release!: (value: Response) => void
  149. const fetchMock = vi.fn(async (path: string) => path === refreshPath
  150. ? new Promise<Response>(resolve => { release = resolve }) : invalid())
  151. vi.stubGlobal('fetch', fetchMock)
  152. await expect(apiRequest('/api/app/action', { method: 'POST', timeoutMs: 20 })).rejects.toMatchObject({ code: 'REQUEST_ABORTED' })
  153. release(response({ csrfToken: 'renewed', userId: 'account-a' }))
  154. await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
  155. expect(fetchMock.mock.calls.filter(([path]) => path !== refreshPath)).toHaveLength(1)
  156. })
  157. it('uses an already renewed token for a late CSRF rejection without renewing twice', async () => {
  158. setApiCsrfToken('expired', 'account-a')
  159. let rejectLate!: (value: Response) => void
  160. const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
  161. if (path === refreshPath) return response({ csrfToken: 'renewed', userId: 'account-a' })
  162. if (new Headers(init?.headers).get('X-CSRF-Token') === 'renewed') return response({ ok: true })
  163. return path.endsWith('/late') ? new Promise<Response>(resolve => { rejectLate = resolve }) : invalid()
  164. })
  165. vi.stubGlobal('fetch', fetchMock)
  166. const late = apiRequest('/api/app/late', { method: 'POST' })
  167. await apiRequest('/api/app/first', { method: 'POST' })
  168. rejectLate(invalid())
  169. await late
  170. expect(fetchMock.mock.calls.filter(([path]) => path === refreshPath)).toHaveLength(1)
  171. })
  172. it('keeps the login context when renewal returns malformed data and allows a later retry', async () => {
  173. setApiCsrfToken('expired', 'account-a')
  174. const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
  175. const fetchMock = vi.fn(async (path: string) => path === refreshPath ? response({}) : invalid())
  176. vi.stubGlobal('fetch', fetchMock)
  177. try {
  178. await expect(apiRequest('/api/app/action', { method: 'POST' })).rejects.toMatchObject({ code: 'CSRF_REFRESH_FAILED' })
  179. expect(expired).not.toHaveBeenCalled()
  180. fetchMock.mockImplementation(async (path: string, init?: RequestInit) => path === refreshPath
  181. ? response({ csrfToken: 'valid-again', userId: 'account-a' })
  182. : new Headers(init?.headers).get('X-CSRF-Token') === 'valid-again' ? response({ ok: true }) : invalid())
  183. await expect(apiRequest('/api/app/action', { method: 'POST' })).resolves.toEqual({ ok: true })
  184. } finally { unsubscribe() }
  185. })
  186. it('preserves the original upload body while renewing its token', async () => {
  187. setApiCsrfToken('expired', 'account-a')
  188. const body = new FormData(); body.set('caption', '原始内容'); body.set('file', new Blob(['original']), 'original.txt')
  189. const snapshots: Array<[string | null, string]> = []
  190. const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
  191. if (path === refreshPath) {
  192. body.set('caption', '后续编辑'); body.set('file', new Blob(['new']), 'new.txt')
  193. return response({ csrfToken: 'new-token', userId: 'account-a' })
  194. }
  195. const sent = init?.body as FormData
  196. snapshots.push([sent.get('caption') as string | null, (sent.get('file') as File).name])
  197. return snapshots.length === 1 ? invalid() : response({ ok: true })
  198. })
  199. vi.stubGlobal('fetch', fetchMock)
  200. await apiRequest('/api/app/uploads', { method: 'POST', body })
  201. expect(snapshots).toEqual([['原始内容', 'original.txt'], ['原始内容', 'original.txt']])
  202. })
  203. it('revalidates another window token instead of silently executing under its account', async () => {
  204. let receive!: (event: { data: unknown }) => void
  205. vi.stubGlobal('BroadcastChannel', class {
  206. addEventListener(_type: string, handler: typeof receive) { receive = handler }
  207. postMessage() {}
  208. })
  209. vi.resetModules()
  210. const client = await import('@/api/client')
  211. client.setApiCsrfToken('account-a-token', 'account-a')
  212. receive({ data: 'account-b-token' })
  213. const fetchMock = vi.fn(async () => response({ csrfToken: 'account-b-token', userId: 'account-b' }))
  214. vi.stubGlobal('fetch', fetchMock)
  215. try {
  216. await expect(client.apiRequest('/api/app/action', { method: 'POST' })).rejects.toMatchObject({ code: 'SECURITY_CONTEXT_CHANGED' })
  217. expect(fetchMock).toHaveBeenCalledExactlyOnceWith(refreshPath, expect.objectContaining({ credentials: 'include' }))
  218. } finally { client.clearApiSecurityContext() }
  219. })
  220. it('serializes expired-cookie renewal across windows using the origin lock', async () => {
  221. let tail = Promise.resolve(), cookie = '', issued = 0
  222. const lock = vi.fn((_name: string, action: () => Promise<void>) => {
  223. const job = tail.then(action); tail = job.catch(() => {}); return job
  224. })
  225. vi.stubGlobal('navigator', { locks: { request: lock } })
  226. vi.resetModules()
  227. const otherWindow = await import('@/api/client')
  228. setApiCsrfToken('expired', 'account-a'); otherWindow.setApiCsrfToken('expired', 'account-a')
  229. const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
  230. if (path === refreshPath) {
  231. const token = cookie || `renewed-${++issued}`
  232. await Promise.resolve(); cookie = token
  233. return response({ csrfToken: token, userId: 'account-a' })
  234. }
  235. return new Headers(init?.headers).get('X-CSRF-Token') === cookie ? response({ ok: true }) : invalid()
  236. })
  237. vi.stubGlobal('fetch', fetchMock)
  238. try {
  239. await Promise.all([apiRequest('/api/app/first-window', { method: 'POST' }), otherWindow.apiRequest('/api/app/second-window', { method: 'POST' })])
  240. expect(issued).toBe(1)
  241. expect(lock).toHaveBeenCalledTimes(2)
  242. } finally { otherWindow.clearApiSecurityContext() }
  243. })
  244. })