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

WIP: 修复局域网安全头与未读降级

[gstack-context]
Decisions: LAN HTTP 仅省略无效 COOP;上游未读 404 降级,其他状态原样透传
Remaining: 全量测试、生产构建、重载 8800 并复验真实 LAN 响应
Skill: /investigate
[/gstack-context]
samien 2 месяцев назад
Родитель
Сommit
8b22ecf1ec
3 измененных файлов с 105 добавлено и 10 удалено
  1. 44 8
      src/server/routes.ts
  2. 5 2
      src/server/security.ts
  3. 56 0
      tests/server/app.test.ts

+ 44 - 8
src/server/routes.ts

@@ -165,6 +165,31 @@ async function proxy(
   })
 }
 
+async function proxyOptionalUnread(
+  ctx: Koa.Context,
+  upstream: UpstreamClient,
+  path: string,
+  options: {
+    search?: URLSearchParams
+    method?: string
+    requestBody?: unknown
+  },
+  fallback: unknown,
+): Promise<void> {
+  await withJar(ctx, async (jar) => {
+    const response = await upstream.request(path, jar, {
+      method: options.method,
+      search: options.search,
+      body: options.requestBody,
+    })
+    if (response.status === 404) {
+      json(ctx, 200, fallback)
+      return
+    }
+    sendUpstreamResponse(ctx, response, jar)
+  })
+}
+
 async function bootstrap(
   ctx: Koa.Context,
   dependencies: RouteDependencies,
@@ -492,17 +517,28 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await searchSessions(ctx, dependencies)
   })
   router.get('/api/app/sessions/unread', async (ctx) => {
-    await proxy(ctx, dependencies.upstream, '/api/session-unread', {
-      search: searchFrom(ctx, ['profile']),
-    })
+    const search = searchFrom(ctx, ['profile'])
+    await proxyOptionalUnread(
+      ctx,
+      dependencies.upstream,
+      '/api/session-unread',
+      { search },
+      { profile: search.get('profile') || '', total_unread: 0, sessions: [], supported: false },
+    )
   })
   router.patch('/api/app/sessions/unread/:sessionID', async (ctx) => {
     const id = safeIdentifier(ctx.params.sessionID, 'session ID')
-    await proxy(ctx, dependencies.upstream, `/api/session-unread/${encodeURIComponent(id)}`, {
-      method: 'PATCH',
-      search: searchFrom(ctx, ['profile']),
-      requestBody: body(ctx),
-    })
+    await proxyOptionalUnread(
+      ctx,
+      dependencies.upstream,
+      `/api/session-unread/${encodeURIComponent(id)}`,
+      {
+        method: 'PATCH',
+        search: searchFrom(ctx, ['profile']),
+        requestBody: body(ctx),
+      },
+      { ok: true, supported: false },
+    )
   })
   router.get('/api/app/sessions', async (ctx) => {
     const search = searchFrom(ctx, ['limit', 'offset', 'order', 'archived', 'profile', 'source'])

+ 5 - 2
src/server/security.ts

@@ -3,7 +3,7 @@ import type { IncomingMessage } from 'node:http'
 import type Koa from 'koa'
 import { parse, serialize } from 'cookie'
 import type { ServerConfig } from './config.js'
-import { isPrivateHost } from './config.js'
+import { isLoopbackHost, isPrivateHost } from './config.js'
 
 export const CSRF_COOKIE = 'hermes_yaoyao_csrf'
 
@@ -161,7 +161,10 @@ export function applySecurityHeaders(ctx: Koa.Context, tls: boolean): void {
     "media-src 'self' blob:",
     `connect-src 'self' ${socketOrigin}`,
   ].join('; '))
-  ctx.set('Cross-Origin-Opener-Policy', 'same-origin')
+  const requestHostname = splitHostPort(ctx.host)?.hostname ?? ''
+  if (tls || isLoopbackHost(requestHostname)) {
+    ctx.set('Cross-Origin-Opener-Policy', 'same-origin')
+  }
   ctx.set('Referrer-Policy', 'no-referrer')
   ctx.set('X-Content-Type-Options', 'nosniff')
   ctx.set('X-Frame-Options', 'DENY')

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

@@ -140,6 +140,62 @@ describe('8800 BFF', () => {
     expect(response.headers['content-security-policy']).toContain("default-src 'self'")
   })
 
+  it('omits opener isolation on plaintext LAN origins but keeps it on loopback', async () => {
+    const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
+    runtimes.push(runtime)
+    const lan = await request(runtime.app.callback())
+      .get('/healthz')
+      .set('Host', '192.168.153.155:8800')
+      .expect(200)
+    expect(lan.headers['cross-origin-opener-policy']).toBeUndefined()
+
+    const loopback = await request(runtime.app.callback())
+      .get('/healthz')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    expect(loopback.headers['cross-origin-opener-policy']).toBe('same-origin')
+  })
+
+  it('degrades unsupported Gateway unread endpoints without browser 404 responses', async () => {
+    const records: RecordedRequest[] = []
+    const baseGateway = fakeGateway(records)
+    const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      if (url.pathname.startsWith('/api/session-unread')) {
+        records.push({
+          path: url.pathname,
+          method: init?.method ?? 'GET',
+          search: url.searchParams,
+          headers: new Headers(init?.headers),
+        })
+        return jsonResponse({ detail: 'Not Found' }, { status: 404 })
+      }
+      return baseGateway(input, init)
+    }) as typeof fetch
+    const runtime = createApplication({ config: makeConfig(), fetchImpl })
+    runtimes.push(runtime)
+
+    const unread = await request(runtime.app.callback())
+      .get('/api/app/sessions/unread?profile=yaoer')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    expect(unread.body).toEqual({ profile: 'yaoer', total_unread: 0, sessions: [], supported: false })
+
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    const marked = await request(runtime.app.callback())
+      .patch('/api/app/sessions/unread/session-1?profile=yaoer')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ readMessageCount: 12 })
+      .expect(200)
+    expect(marked.body).toEqual({ ok: true, supported: false })
+  })
+
   it('requires exact Origin and CSRF on login, then sequences the Gateway login', async () => {
     const records: RecordedRequest[] = []
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })