Quellcode durchsuchen

修复桌面长期打开后的 CSRF 过期并安全续领令牌

Samien vor 1 Monat
Ursprung
Commit
77be5dd756

+ 30 - 0
docs/verification/2026-09-14-desktop-csrf/README.md

@@ -0,0 +1,30 @@
+# 桌面长期打开后的 CSRF 自动恢复
+
+基线:主分支 `a23f030`(v0.4.17)。按用户要求直接在主分支开发。
+
+## 原因与修改
+
+本地登录 Cookie 有效期为 30 天,CSRF Cookie 为 8 小时。页面一直保留旧的内存令牌;Cookie 到期后,后续写入请求收到 `403 invalid_csrf`,此前没有自动恢复路径。
+
+统一 API 客户端现在会在缺少令牌时准备令牌,或在收到明确的 `403 invalid_csrf` 后续领,再重试原请求一次。续领使用 `GET /api/app/bootstrap?csrfOnly=1`,新服务端仅返回 CSRF 令牌与当前账号 ID,不访问 9119、不加载机器人列表;旧服务端忽略参数后返回的完整 bootstrap 仍可读取。响应禁止缓存。
+
+同一窗口的请求共用一次续领。支持 Web Locks 的同源窗口还会串行签发,避免同时收到两个新 Cookie 相互覆盖;跨窗口令牌通知触发身份核验,而不是直接拿另一窗口的令牌重放操作。
+
+原 Cookie 有效期、签名校验、Origin 校验和登录权限保持原规则。只有业务执行前的明确 CSRF 拒绝才允许重试;网络失败、超时、权限拒绝和一般服务端错误不会自动重试。原请求 ID、请求体和附件保持一致。取消、超时或切换账号后不会重放旧操作。
+
+## 验证
+
+运行:
+
+```sh
+bash docs/verification/2026-09-14-desktop-csrf/verify.sh
+```
+
+- 使用真实 `CsrfProtection` 签名逻辑及模拟浏览器 Cookie 时钟,将时间推进到 8 小时之后;第一次请求被 CSRF 拦截,自动续领后成功,业务执行计数为 1。
+- 普通聊天、Bot 消息和设置并发时只续领一次;晚到的旧令牌拒绝复用已经更新的令牌。
+- 覆盖旧服务端完整 bootstrap、缺失令牌、续领失败、重复拒绝、网络失败、原请求超时、取消、附件快照和账号切换。
+- 覆盖多窗口 Cookie 签发串行和跨账号令牌通知。
+- 实际 HTTP 路由验证匿名、管理员、普通账号均能续领,且不访问上游;保留既有 Origin、CSRF 和服务重启校验测试。
+- 全部客户端测试及相关服务端测试 **66 个文件、378 项通过**;类型检查通过。
+
+此次为自动化时钟和协议测试,没有让真实桌面程序等待 8 小时,也没有重启用户服务或更新安装包。结果见 `results.txt`。

+ 10 - 0
docs/verification/2026-09-14-desktop-csrf/results.txt

@@ -0,0 +1,10 @@
+Command: NODE_OPTIONS=--no-experimental-webstorage npm test -- tests/client tests/server/app.test.ts tests/server/security.test.ts tests/server/realtimeApi.test.ts
+
+Test Files  66 passed (66)
+      Tests  378 passed (378)
+   Start at  08:04:13
+   Duration  4.53s (transform 5.72s, setup 496ms, collect 20.41s, tests 5.13s, environment 20.59s, prepare 2.59s)
+
+> yaoyao@0.4.17 typecheck
+> vue-tsc -b && tsc --noEmit -p tsconfig.server.json
+Typecheck exit code: 0

+ 6 - 0
docs/verification/2026-09-14-desktop-csrf/verify.sh

@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)"
+export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--no-experimental-webstorage"
+npm test -- tests/client tests/server/app.test.ts tests/server/security.test.ts tests/server/realtimeApi.test.ts
+npm run typecheck

+ 1 - 1
src/client/api/auth.ts

@@ -25,7 +25,7 @@ function normalizeBootstrap(payload: unknown): BootstrapResponse {
     serverIdentity: serverIdentityValue(root.serverIdentity),
   }
   if (rawUser) response.user = normalizeUser(rawUser)
-  setApiCsrfToken(csrfToken)
+  setApiCsrfToken(csrfToken, response.user?.id ?? (authRequired ? null : 'local'))
   return response
 }
 

+ 121 - 35
src/client/api/client.ts

@@ -1,4 +1,5 @@
 import type { JsonValue } from '@shared/types'
+import { bool, normalizeUser, record, string } from '@/utils/normalize'
 
 export class ApiError extends Error {
   readonly status: number
@@ -15,21 +16,84 @@ export class ApiError extends Error {
 }
 
 let csrfToken = ''
+let csrfAccountId: string | null | undefined
+let securityGeneration = 0
+let csrfRefresh: { generation: number; promise: Promise<void> } | undefined
 const unauthorizedListeners = new Set<() => void>()
 const securityChannel = typeof window !== 'undefined' && typeof window.BroadcastChannel !== 'undefined'
   ? new window.BroadcastChannel('hermes-yaoyao-security')
   : undefined
 securityChannel?.addEventListener('message', event => {
-  if (typeof event.data === 'string') csrfToken = event.data
+  // Another window shares cookies, but may now belong to another account.
+  // Revalidate that identity before using its token for a pending action.
+  if (typeof event.data === 'string' && event.data !== csrfToken) csrfToken = ''
 })
 
-export function setApiCsrfToken(token?: string | null): void {
+export function setApiCsrfToken(token?: string | null, accountId?: string | null): void {
+  if (accountId !== undefined) {
+    if (csrfAccountId !== undefined && csrfAccountId !== accountId) securityGeneration += 1
+    csrfAccountId = accountId
+  }
   csrfToken = token?.trim() ?? ''
   securityChannel?.postMessage(csrfToken)
 }
 
 export function clearApiSecurityContext(): void {
+  securityGeneration += 1
   csrfToken = ''
+  csrfAccountId = undefined
+  csrfRefresh = undefined
+}
+
+function requireSecurityGeneration(generation: number): void {
+  if (generation !== securityGeneration) throw new ApiError('登录状态已变化,请重新操作', 0, 'SECURITY_CONTEXT_CHANGED')
+}
+
+function refreshCsrfToken(rejectedToken: string): Promise<void> {
+  if (csrfToken && csrfToken !== rejectedToken) return Promise.resolve()
+  const generation = securityGeneration, accountId = csrfAccountId
+  if (csrfRefresh?.generation === generation) return csrfRefresh.promise
+  const renew = async () => {
+    requireSecurityGeneration(generation)
+    const payload = await apiRequest<unknown>('/api/app/bootstrap?csrfOnly=1', {
+      csrf: false, notifyUnauthorized: false, timeoutMs: 10_000,
+    })
+    requireSecurityGeneration(generation)
+    const root = record(unwrapData(payload)), status = record(root.status)
+    const user = root.user ?? root.identity
+    const token = string(root.csrfToken ?? root.csrf_token ?? root.csrf).trim()
+    const authRequired = root.authRequired ?? root.auth_required ?? status.authRequired ?? status.auth_required
+    if (!token || (!('userId' in root) && !user && authRequired === undefined)
+      || ('userId' in root && root.userId !== null && typeof root.userId !== 'string'))
+      throw new ApiError('无法更新安全令牌,请稍后重试', 0, 'CSRF_REFRESH_FAILED')
+    // Older servers ignore csrfOnly and return a complete bootstrap response.
+    const currentAccount = 'userId' in root ? (typeof root.userId === 'string' ? root.userId : null)
+      : user ? normalizeUser(user).id
+        : bool(authRequired) ? null : 'local'
+    if (accountId !== undefined && currentAccount !== accountId) {
+      clearApiSecurityContext()
+      for (const listener of unauthorizedListeners) listener()
+      throw new ApiError('登录账号已变化,请重新登录后操作', 401, 'SECURITY_CONTEXT_CHANGED')
+    }
+    setApiCsrfToken(token, currentAccount)
+  }
+  // Cookie issuance must also be serialized across windows when supported;
+  // otherwise two expired-cookie responses can replace one another's token.
+  const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined
+  const promise = (locks ? locks.request('hermes-yaoyao-csrf-refresh', renew) : renew())
+    .finally(() => { if (csrfRefresh?.promise === promise) csrfRefresh = undefined })
+  csrfRefresh = { generation, promise }
+  return promise
+}
+
+function waitForCsrfRefresh(promise: Promise<void>, signal: AbortSignal): Promise<void> {
+  return new Promise((resolve, reject) => {
+    const abort = () => { cleanup(); reject(new DOMException('Request aborted', 'AbortError')) }
+    const cleanup = () => signal.removeEventListener('abort', abort)
+    signal.addEventListener('abort', abort, { once: true })
+    promise.then(() => { cleanup(); resolve() }, error => { cleanup(); reject(error) })
+    if (signal.aborted) abort()
+  })
 }
 
 export function onApiUnauthorized(listener: () => void): () => void {
@@ -76,17 +140,19 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
   if (!path.startsWith('/api/app/') && !path.startsWith('/api/realtime/')) throw new ApiError('客户端仅允许访问应用接口', 0, 'INVALID_PATH')
 
   const method = (options.method ?? 'GET').toUpperCase()
+  const requiresCsrf = options.csrf !== false && !['GET', 'HEAD', 'OPTIONS'].includes(method)
+  const generation = securityGeneration
   const headers = new Headers(options.headers)
   headers.set('Accept', 'application/json')
-  if (options.csrf !== false && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
-    if (!csrfToken) throw new ApiError('安全令牌尚未就绪,请刷新后重试', 0, 'CSRF_MISSING')
-    headers.set('X-CSRF-Token', csrfToken)
-  }
 
   let body: BodyInit | undefined
   if (options.body !== undefined) {
     if (isBodyInit(options.body)) {
-      body = options.body
+      if (typeof FormData !== 'undefined' && options.body instanceof FormData) {
+        const snapshot = new FormData()
+        options.body.forEach((value, key) => snapshot.append(key, value))
+        body = snapshot
+      } else body = options.body
     } else {
       headers.set('Content-Type', 'application/json')
       body = JSON.stringify(options.body)
@@ -97,37 +163,57 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
   const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs ?? 30_000)
   const abort = () => controller.abort(options.signal?.reason)
   options.signal?.addEventListener('abort', abort, { once: true })
+  if (options.signal?.aborted) abort()
   try {
-    const response = await fetch(path, {
-      ...options,
-      method,
-      headers,
-      body,
-      credentials: 'include',
-      cache: 'no-store',
-      signal: controller.signal,
-    })
-    const contentType = response.headers.get('content-type') ?? ''
-    const payload = response.status === 204
-      ? undefined
-      : contentType.includes('application/json')
-        ? await response.json().catch(() => undefined)
-        : await response.text().catch(() => undefined)
-    if (!response.ok) {
-      const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : undefined
-      const code = typeof record?.code === 'string' ? record.code : undefined
-      if (response.status === 401
-        && options.notifyUnauthorized !== false) {
-        for (const listener of unauthorizedListeners) listener()
+    if (controller.signal.aborted) throw new DOMException('Request aborted', 'AbortError')
+    if (requiresCsrf && !csrfToken) await waitForCsrfRefresh(refreshCsrfToken(''), controller.signal)
+    for (let attempt = 0; attempt < 2; attempt += 1) {
+      const requestHeaders = new Headers(headers)
+      if (controller.signal.aborted) throw new DOMException('Request aborted', 'AbortError')
+      if (requiresCsrf) {
+        requireSecurityGeneration(generation)
+        requestHeaders.set('X-CSRF-Token', csrfToken)
+      }
+      const sentToken = requestHeaders.get('X-CSRF-Token') ?? ''
+      const response = await fetch(path, {
+        ...options,
+        method,
+        headers: requestHeaders,
+        body,
+        credentials: 'include',
+        cache: 'no-store',
+        signal: controller.signal,
+      })
+      const contentType = response.headers.get('content-type') ?? ''
+      const payload = response.status === 204
+        ? undefined
+        : contentType.includes('application/json')
+          ? await response.json().catch(() => undefined)
+          : await response.text().catch(() => undefined)
+      if (!response.ok) {
+        const record = payload && typeof payload === 'object' ? payload as Record<string, unknown> : undefined
+        const code = typeof record?.code === 'string' ? record.code : undefined
+        if (requiresCsrf && attempt === 0 && response.status === 403 && code === 'invalid_csrf') {
+          requireSecurityGeneration(generation)
+          // This explicit rejection occurs before the server executes the action.
+          // Preserve the original body and idempotency key; never retry timeouts.
+          await waitForCsrfRefresh(refreshCsrfToken(sentToken), controller.signal)
+          continue
+        }
+        if (response.status === 401
+          && options.notifyUnauthorized !== false) {
+          for (const listener of unauthorizedListeners) listener()
+        }
+        throw new ApiError(
+          errorMessage(payload, `请求失败(${response.status})`),
+          response.status,
+          code,
+          payload as JsonValue,
+        )
       }
-      throw new ApiError(
-        errorMessage(payload, `请求失败(${response.status})`),
-        response.status,
-        code,
-        payload as JsonValue,
-      )
+      return payload as T
     }
-    return payload as T
+    throw new ApiError('安全令牌校验失败,请稍后重试', 403, 'invalid_csrf')
   } catch (error) {
     if (error instanceof ApiError) throw error
     if (controller.signal.aborted) throw new ApiError('请求已取消或超时', 0, 'REQUEST_ABORTED')

+ 8 - 0
src/server/routes.ts

@@ -1677,6 +1677,14 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.get('/api/app/bootstrap', async (ctx) => {
+    ctx.set('Cache-Control', 'no-store')
+    if (ctx.query.csrfOnly === '1') {
+      json(ctx, 200, {
+        csrfToken: dependencies.csrf.issue(ctx),
+        userId: dependencies.auth.current(ctx)?.id ?? null,
+      })
+      return
+    }
     await bootstrap(ctx, dependencies)
   })
   router.post('/api/app/setup', async (ctx) => {

+ 261 - 0
tests/client/csrfRecovery.test.ts

@@ -0,0 +1,261 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type Koa from 'koa'
+import { CsrfProtection } from '../../src/server/security'
+import { apiRequest, clearApiSecurityContext, onApiUnauthorized, setApiCsrfToken } from '@/api/client'
+
+const refreshPath = '/api/app/bootstrap?csrfOnly=1'
+const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
+  status, headers: { 'content-type': 'application/json' },
+})
+const invalid = () => response({ code: 'invalid_csrf', message: 'CSRF token is invalid or missing' }, 403)
+afterEach(() => { clearApiSecurityContext(); vi.useRealTimers(); vi.unstubAllGlobals() })
+
+describe('long-lived desktop CSRF recovery', () => {
+  it('renews an expired eight-hour signed cookie and executes the original action exactly once', async () => {
+    const csrf = new CsrfProtection()
+    let clock = 1000, cookie = '', expiresAt = 0, executed = 0
+    const renew = () => {
+      const headers: Record<string, unknown> = {}
+      const ctx = { response: { headers }, get: () => clock < expiresAt ? cookie : '',
+        set: (key: string, value: unknown) => { headers[key.toLowerCase()] = value } } as unknown as Koa.Context
+      const token = csrf.issue(ctx)
+      if (headers['set-cookie']) {
+        const value = (headers['set-cookie'] as string[])[0]!
+        expect(value).toContain('Max-Age=28800')
+        cookie = value.split(';')[0]!; expiresAt = clock + 28_800_000
+      }
+      return token
+    }
+    const old = renew()
+    setApiCsrfToken(old, 'account-a')
+    clock += 28_800_001
+    const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
+      if (path === refreshPath) return response({ csrfToken: renew(), userId: 'account-a' })
+      if (!csrf.verify(clock < expiresAt ? cookie : '', new Headers(init?.headers).get('X-CSRF-Token') ?? undefined)) return invalid()
+      executed += 1
+      return response({ accepted: true })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    const body = { requestId: 'original-send-id', content: '只发送一次' }
+    await expect(apiRequest('/api/app/conversations/session/messages', {
+      method: 'POST', body, headers: { 'Idempotency-Key': 'original-send-id' },
+    })).resolves.toEqual({ accepted: true })
+    expect(executed).toBe(1)
+    const mutations = fetchMock.mock.calls.filter(([path]) => path !== refreshPath)
+    expect(mutations).toHaveLength(2)
+    expect(mutations.map(([, init]) => init?.body)).toEqual([JSON.stringify(body), JSON.stringify(body)])
+    expect(mutations.map(([, init]) => new Headers(init?.headers).get('Idempotency-Key'))).toEqual(['original-send-id', 'original-send-id'])
+    expect(new Headers(mutations[0]![1]?.headers).get('X-CSRF-Token')).toBe(old)
+    expect(new Headers(mutations[1]![1]?.headers).get('X-CSRF-Token')).not.toBe(old)
+  })
+
+  it('shares one refresh across concurrent ordinary-chat and Bot requests', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    let release!: (value: Response) => void
+    const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
+      if (path === refreshPath) return new Promise<Response>(resolve => { release = resolve })
+      return new Headers(init?.headers).get('X-CSRF-Token') === 'renewed' ? response({ ok: true }) : invalid()
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    const paths = ['/api/realtime/channels/chat/commands', '/api/app/conversations/bot/messages', '/api/app/account/avatar']
+    const jobs = paths.map(path => apiRequest(path, { method: 'POST', body: { requestId: path } }))
+    await vi.waitFor(() => expect(fetchMock.mock.calls.filter(([path]) => path === refreshPath)).toHaveLength(1))
+    release(response({ csrfToken: 'renewed', userId: 'account-a' }))
+    await Promise.all(jobs)
+    expect(fetchMock.mock.calls.filter(([path]) => path === refreshPath)).toHaveLength(1)
+    for (const path of paths) expect(fetchMock.mock.calls.filter(([value]) => value === path)).toHaveLength(2)
+  })
+
+  it('prepares a missing token and accepts a full bootstrap from an older server', async () => {
+    const fetchMock = vi.fn(async (path: string) => path === refreshPath
+      ? response({ csrfToken: 'legacy-token', user: { id: 'account-a' }, authRequired: true, profiles: [] })
+      : response({ ok: true }))
+    vi.stubGlobal('fetch', fetchMock)
+    await apiRequest('/api/app/account/avatar', { method: 'PUT', body: {} })
+    expect(fetchMock.mock.calls.map(([path]) => path)).toEqual([refreshPath, '/api/app/account/avatar'])
+  })
+
+  it.each([['invalid_origin', 403], ['permission_denied', 403], ['upstream_failure', 500]])(
+    'does not retry %s failures', async (code, status) => {
+      setApiCsrfToken('valid', 'account-a')
+      const fetchMock = vi.fn(async () => response({ code }, Number(status)))
+      vi.stubGlobal('fetch', fetchMock)
+      await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code })
+      expect(fetchMock).toHaveBeenCalledTimes(1)
+    },
+  )
+
+  it('does not retry network failures with an unknown submission result', async () => {
+    setApiCsrfToken('valid', 'account-a')
+    const fetchMock = vi.fn(async () => { throw new TypeError('Connection lost') })
+    vi.stubGlobal('fetch', fetchMock)
+    await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
+    expect(fetchMock).toHaveBeenCalledTimes(1)
+  })
+
+  it('stops after one retry when the refreshed token is still rejected', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    const fetchMock = vi.fn(async (path: string) => path === refreshPath
+      ? response({ csrfToken: 'new-token', userId: 'account-a' }) : invalid())
+    vi.stubGlobal('fetch', fetchMock)
+    await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'invalid_csrf' })
+    expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(['/api/app/action', refreshPath, '/api/app/action'])
+  })
+
+  it('does not replay an old account action after a cookie account change', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
+    const fetchMock = vi.fn(async (path: string) => path === refreshPath
+      ? response({ csrfToken: 'account-b-token', userId: 'account-b' }) : invalid())
+    vi.stubGlobal('fetch', fetchMock)
+    try {
+      await expect(apiRequest('/api/app/action', { method: 'POST', body: {} })).rejects.toMatchObject({ code: 'SECURITY_CONTEXT_CHANGED' })
+      expect(fetchMock.mock.calls.filter(([path]) => path !== refreshPath)).toHaveLength(1)
+      expect(expired).toHaveBeenCalledTimes(1)
+    } finally { unsubscribe() }
+  })
+
+  it('discards a refresh completed after local logout', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    let release!: (value: Response) => void
+    const fetchMock = vi.fn(async (path: string) => path === refreshPath
+      ? new Promise<Response>(resolve => { release = resolve }) : invalid())
+    vi.stubGlobal('fetch', fetchMock)
+    const sending = apiRequest('/api/app/action', { method: 'POST', body: {} }).catch(error => error)
+    await vi.waitFor(() => expect(release).toBeTypeOf('function'))
+    clearApiSecurityContext(); setApiCsrfToken('account-b-token', 'account-b')
+    release(response({ csrfToken: 'late-a-token', userId: 'account-a' }))
+    expect(await sending).toMatchObject({ code: 'SECURITY_CONTEXT_CHANGED' })
+    expect(fetchMock.mock.calls.filter(([path]) => path !== refreshPath)).toHaveLength(1)
+  })
+
+  it('cancels one waiting request without cancelling another request sharing the refresh', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    let release!: (value: Response) => void
+    const fetchMock = vi.fn(async (path: string, init?: RequestInit) => path === refreshPath
+      ? new Promise<Response>(resolve => { release = resolve })
+      : new Headers(init?.headers).get('X-CSRF-Token') === 'renewed' ? response({ ok: true }) : invalid())
+    vi.stubGlobal('fetch', fetchMock)
+    const abort = new AbortController()
+    const cancelled = apiRequest('/api/app/cancelled', { method: 'POST', signal: abort.signal }).catch(error => error)
+    const retained = apiRequest('/api/app/retained', { method: 'POST' })
+    await vi.waitFor(() => expect(release).toBeTypeOf('function'))
+    abort.abort()
+    expect(await cancelled).toMatchObject({ code: 'REQUEST_ABORTED' })
+    release(response({ csrfToken: 'renewed', userId: 'account-a' }))
+    await expect(retained).resolves.toEqual({ ok: true })
+    expect(fetchMock.mock.calls.filter(([path]) => path === '/api/app/cancelled')).toHaveLength(1)
+    expect(fetchMock.mock.calls.filter(([path]) => path === '/api/app/retained')).toHaveLength(2)
+  })
+
+  it('does not send an already cancelled action', async () => {
+    const fetchMock = vi.fn(), abort = new AbortController(); abort.abort()
+    vi.stubGlobal('fetch', fetchMock)
+    await expect(apiRequest('/api/app/action', { method: 'POST', signal: abort.signal })).rejects.toMatchObject({ code: 'REQUEST_ABORTED' })
+    expect(fetchMock).not.toHaveBeenCalled()
+  })
+
+  it('honors the original request deadline while renewal is pending', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    let release!: (value: Response) => void
+    const fetchMock = vi.fn(async (path: string) => path === refreshPath
+      ? new Promise<Response>(resolve => { release = resolve }) : invalid())
+    vi.stubGlobal('fetch', fetchMock)
+    await expect(apiRequest('/api/app/action', { method: 'POST', timeoutMs: 20 })).rejects.toMatchObject({ code: 'REQUEST_ABORTED' })
+    release(response({ csrfToken: 'renewed', userId: 'account-a' }))
+    await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
+    expect(fetchMock.mock.calls.filter(([path]) => path !== refreshPath)).toHaveLength(1)
+  })
+
+  it('uses an already renewed token for a late CSRF rejection without renewing twice', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    let rejectLate!: (value: Response) => void
+    const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
+      if (path === refreshPath) return response({ csrfToken: 'renewed', userId: 'account-a' })
+      if (new Headers(init?.headers).get('X-CSRF-Token') === 'renewed') return response({ ok: true })
+      return path.endsWith('/late') ? new Promise<Response>(resolve => { rejectLate = resolve }) : invalid()
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    const late = apiRequest('/api/app/late', { method: 'POST' })
+    await apiRequest('/api/app/first', { method: 'POST' })
+    rejectLate(invalid())
+    await late
+    expect(fetchMock.mock.calls.filter(([path]) => path === refreshPath)).toHaveLength(1)
+  })
+
+  it('keeps the login context when renewal returns malformed data and allows a later retry', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
+    const fetchMock = vi.fn(async (path: string) => path === refreshPath ? response({}) : invalid())
+    vi.stubGlobal('fetch', fetchMock)
+    try {
+      await expect(apiRequest('/api/app/action', { method: 'POST' })).rejects.toMatchObject({ code: 'CSRF_REFRESH_FAILED' })
+      expect(expired).not.toHaveBeenCalled()
+      fetchMock.mockImplementation(async (path: string, init?: RequestInit) => path === refreshPath
+        ? response({ csrfToken: 'valid-again', userId: 'account-a' })
+        : new Headers(init?.headers).get('X-CSRF-Token') === 'valid-again' ? response({ ok: true }) : invalid())
+      await expect(apiRequest('/api/app/action', { method: 'POST' })).resolves.toEqual({ ok: true })
+    } finally { unsubscribe() }
+  })
+
+  it('preserves the original upload body while renewing its token', async () => {
+    setApiCsrfToken('expired', 'account-a')
+    const body = new FormData(); body.set('caption', '原始内容'); body.set('file', new Blob(['original']), 'original.txt')
+    const snapshots: Array<[string | null, string]> = []
+    const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
+      if (path === refreshPath) {
+        body.set('caption', '后续编辑'); body.set('file', new Blob(['new']), 'new.txt')
+        return response({ csrfToken: 'new-token', userId: 'account-a' })
+      }
+      const sent = init?.body as FormData
+      snapshots.push([sent.get('caption') as string | null, (sent.get('file') as File).name])
+      return snapshots.length === 1 ? invalid() : response({ ok: true })
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    await apiRequest('/api/app/uploads', { method: 'POST', body })
+    expect(snapshots).toEqual([['原始内容', 'original.txt'], ['原始内容', 'original.txt']])
+  })
+
+  it('revalidates another window token instead of silently executing under its account', async () => {
+    let receive!: (event: { data: unknown }) => void
+    vi.stubGlobal('BroadcastChannel', class {
+      addEventListener(_type: string, handler: typeof receive) { receive = handler }
+      postMessage() {}
+    })
+    vi.resetModules()
+    const client = await import('@/api/client')
+    client.setApiCsrfToken('account-a-token', 'account-a')
+    receive({ data: 'account-b-token' })
+    const fetchMock = vi.fn(async () => response({ csrfToken: 'account-b-token', userId: 'account-b' }))
+    vi.stubGlobal('fetch', fetchMock)
+    try {
+      await expect(client.apiRequest('/api/app/action', { method: 'POST' })).rejects.toMatchObject({ code: 'SECURITY_CONTEXT_CHANGED' })
+      expect(fetchMock).toHaveBeenCalledExactlyOnceWith(refreshPath, expect.objectContaining({ credentials: 'include' }))
+    } finally { client.clearApiSecurityContext() }
+  })
+
+  it('serializes expired-cookie renewal across windows using the origin lock', async () => {
+    let tail = Promise.resolve(), cookie = '', issued = 0
+    const lock = vi.fn((_name: string, action: () => Promise<void>) => {
+      const job = tail.then(action); tail = job.catch(() => {}); return job
+    })
+    vi.stubGlobal('navigator', { locks: { request: lock } })
+    vi.resetModules()
+    const otherWindow = await import('@/api/client')
+    setApiCsrfToken('expired', 'account-a'); otherWindow.setApiCsrfToken('expired', 'account-a')
+    const fetchMock = vi.fn(async (path: string, init?: RequestInit) => {
+      if (path === refreshPath) {
+        const token = cookie || `renewed-${++issued}`
+        await Promise.resolve(); cookie = token
+        return response({ csrfToken: token, userId: 'account-a' })
+      }
+      return new Headers(init?.headers).get('X-CSRF-Token') === cookie ? response({ ok: true }) : invalid()
+    })
+    vi.stubGlobal('fetch', fetchMock)
+    try {
+      await Promise.all([apiRequest('/api/app/first-window', { method: 'POST' }), otherWindow.apiRequest('/api/app/second-window', { method: 'POST' })])
+      expect(issued).toBe(1)
+      expect(lock).toHaveBeenCalledTimes(2)
+    } finally { otherWindow.clearApiSecurityContext() }
+  })
+})

+ 22 - 1
tests/server/app.test.ts

@@ -5,7 +5,7 @@ import request from 'supertest'
 import { afterEach, describe, expect, it } from 'vitest'
 import { createApplication as createUnauthenticatedApplication, type ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
-import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
+import { createAuthenticatedApplication as createApplication, createUserAuthenticatedApplication } from './authenticatedApplication.js'
 import { saveFileAccess } from '../../src/server/fileAccess.js'
 
 interface RecordedRequest {
@@ -106,6 +106,27 @@ function cookieHeader(response: request.Response): string {
 }
 
 describe('15300 BFF', () => {
+  it.each([
+    { create: createUnauthenticatedApplication, userId: null },
+    { create: createApplication, userId: 'test-admin' },
+    { create: createUserAuthenticatedApplication, userId: 'test-user' },
+  ])('renews CSRF for $userId without requesting upstream profiles or status', async ({ create, userId }) => {
+    const records: RecordedRequest[] = []
+    const runtime = create({ config: makeConfig(), fetchImpl: fakeGateway(records) })
+    runtimes.push(runtime)
+    const result = await request(runtime.app.callback()).get('/api/app/bootstrap?csrfOnly=1')
+      .set('Host', '127.0.0.1:15300').expect(200)
+    expect(result.body).toEqual({ csrfToken: expect.any(String), userId })
+    expect(runtime.csrf.verify(cookieHeader(result), result.body.csrfToken)).toBe(true)
+    expect(result.headers['set-cookie'][0]).toContain('Max-Age=28800')
+    expect(result.headers['cache-control']).toContain('no-store')
+    expect(records).toEqual([])
+    const again = await request(runtime.app.callback()).get('/api/app/bootstrap?csrfOnly=1')
+      .set('Host', '127.0.0.1:15300').set('Cookie', cookieHeader(result)).expect(200)
+    expect(again.body.csrfToken).toBe(result.body.csrfToken)
+    expect(records).toEqual([])
+  })
+
   it('keeps existing CSRF tokens valid across an 15300 restart', async () => {
     const config = makeConfig()
     const first = createApplication({ config, fetchImpl: fakeGateway([]) })