Parcourir la source

fix: 映射白名单反代 Origin 与实时租约

samien il y a 2 mois
Parent
commit
154ccafc37
5 fichiers modifiés avec 32 ajouts et 5 suppressions
  1. 1 1
      src/server/app.ts
  2. 7 2
      src/server/routes.ts
  3. 17 1
      src/server/security.ts
  4. 1 1
      src/server/websocket.ts
  5. 6 0
      tests/server/security.test.ts

+ 1 - 1
src/server/app.ts

@@ -115,7 +115,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
   app.use(async (ctx, next) => {
     if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
       const secure = ctx.secure || Boolean(config.tlsCert)
-      if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure)) {
+      if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
         throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
       }
       if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {

+ 7 - 2
src/server/routes.ts

@@ -8,7 +8,7 @@ import type { ServerConfig } from './config.js'
 import { isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
-import { accountKeyFromCookieHeader, CsrfProtection, expectedRequestOrigin, requestAccountKey } from './security.js'
+import { acceptedRequestOrigin, accountKeyFromCookieHeader, CsrfProtection, requestAccountKey } from './security.js'
 import {
   applyUpstreamCookies,
   CookieJar,
@@ -335,7 +335,12 @@ async function issueLease(
     credential = { name: 'token', value: match[1] }
   }
 
-  const origin = expectedRequestOrigin(ctx.host, ctx.secure || Boolean(dependencies.config.tlsCert))
+  const origin = acceptedRequestOrigin(
+    ctx.get('origin'),
+    ctx.get('host'),
+    ctx.secure || Boolean(dependencies.config.tlsCert),
+    dependencies.config.allowedHosts,
+  )
   if (!origin) throw new HttpError(400, 'Invalid request host', 'invalid_host')
   const accountKeys = new Set([
     requestAccountKey(ctx.req),

+ 17 - 1
src/server/security.ts

@@ -54,6 +54,7 @@ export function isExactOrigin(
   origin: string | undefined,
   host: string | undefined,
   secure: boolean,
+  allowedHosts: ReadonlySet<string> = new Set(),
 ): boolean {
   if (!origin || !host) return false
   const expected = expectedRequestOrigin(host, secure)
@@ -62,7 +63,22 @@ export function isExactOrigin(
   if (actual === expected) return true
   // A TLS-terminating reverse proxy forwards HTTP internally while the
   // browser's same-origin request correctly carries an https Origin.
-  return !secure && actual === expectedRequestOrigin(host, true)
+  if (!secure && actual === expectedRequestOrigin(host, true)) return true
+  try {
+    return allowedHosts.has(new URL(actual).hostname.toLowerCase().replace(/\.$/, ''))
+  } catch {
+    return false
+  }
+}
+
+export function acceptedRequestOrigin(
+  origin: string | undefined,
+  host: string | undefined,
+  secure: boolean,
+  allowedHosts: ReadonlySet<string>,
+): string | null {
+  if (!isExactOrigin(origin, host, secure, allowedHosts)) return null
+  return canonicalOrigin(origin!)
 }
 
 export function appendSetCookies(ctx: Koa.Context, values: readonly string[]): void {

+ 1 - 1
src/server/websocket.ts

@@ -407,7 +407,7 @@ export function installWebSocketRelay(
     const host = request.headers.host
     const origin = typeof request.headers.origin === 'string' ? request.headers.origin : undefined
     const secure = Boolean(config.tlsCert)
-    if (!isAllowedHostHeader(host, config) || !isExactOrigin(origin, host, secure)) {
+    if (!isAllowedHostHeader(host, config) || !isExactOrigin(origin, host, secure, config.allowedHosts)) {
       rejectUpgrade(socket, 403, 'Host or Origin rejected')
       return
     }

+ 6 - 0
tests/server/security.test.ts

@@ -49,6 +49,12 @@ describe('server security boundary', () => {
     expect(isExactOrigin('https://attacker.example', 'yaoyao.internal', false)).toBe(false)
   })
 
+  it('accepts a configured public Origin when the reverse proxy rewrites Host internally', () => {
+    const allowed = new Set(['yaoyao-lc.samien.cn'])
+    expect(isExactOrigin('http://yaoyao-lc.samien.cn', '10.1.5.100', false, allowed)).toBe(true)
+    expect(isExactOrigin('http://attacker.example', '10.1.5.100', false, allowed)).toBe(false)
+  })
+
   it('requires an exact scheme and host Origin', () => {
     expect(isExactOrigin('http://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)
     expect(isExactOrigin('https://127.0.0.1:8800', '127.0.0.1:8800', false)).toBe(true)