Просмотр исходного кода

fix: 重载后保持 CSRF 会话有效

samien 2 месяцев назад
Родитель
Сommit
15dd355800
2 измененных файлов с 38 добавлено и 2 удалено
  1. 26 2
      src/server/app.ts
  2. 12 0
      tests/server/app.test.ts

+ 26 - 2
src/server/app.ts

@@ -1,7 +1,8 @@
 import { randomBytes } from 'node:crypto'
-import { readFileSync } from 'node:fs'
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
 import { createServer as createHttpsServer } from 'node:https'
+import { join } from 'node:path'
 import { bodyParser } from '@koa/bodyparser'
 import Koa from 'koa'
 import type { ServerConfig } from './config.js'
@@ -41,11 +42,34 @@ function isMutation(method: string): boolean {
   return !['GET', 'HEAD', 'OPTIONS'].includes(method)
 }
 
+function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
+  if (override) return override
+  const path = join(home, 'csrf-secret.bin')
+  try {
+    const secret = readFileSync(path)
+    if (secret.byteLength === 32) return secret
+    throw new Error('CSRF secret has an invalid length')
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+  }
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const generated = randomBytes(32)
+  try {
+    writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
+    return generated
+  } catch (error) {
+    // Another 8800 process can finish first during launchd hand-off. Never
+    // rotate its secret; use the one it safely wrote instead.
+    if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
+    throw error
+  }
+}
+
 export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
   const config = options.config ?? loadServerConfig()
   const app = new Koa()
   app.proxy = false
-  const csrf = new CsrfProtection(options.csrfSecret ?? randomBytes(32), Boolean(config.tlsCert))
+  const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
   const leases = options.leases ?? new RealtimeLeaseStore()
   const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
   const uploads = options.uploads ?? new UploadStore(config.home)

+ 12 - 0
tests/server/app.test.ts

@@ -97,6 +97,18 @@ function cookieHeader(response: request.Response): string {
 }
 
 describe('8800 BFF', () => {
+  it('keeps existing CSRF tokens valid across an 8800 restart', async () => {
+    const config = makeConfig()
+    const first = createApplication({ config, fetchImpl: fakeGateway([]) })
+    const bootstrap = await request(first.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const cookies = cookieHeader(bootstrap)
+    first.close()
+
+    const restarted = createApplication({ config, fetchImpl: fakeGateway([]) })
+    runtimes.push(restarted)
+    expect(restarted.csrf.verify(cookies, bootstrap.body.csrfToken)).toBe(true)
+  })
   it('serves authenticated historical media only from the configured root', async () => {
     const config = makeConfig()
     writeFileSync(join(config.mediaRoot, '报告.txt'), '历史文件内容')