From c13f5a6e1cd322cff2ceb19f2393103774ad2c94 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 25 Aug 2026 12:10:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E5=90=8C=E6=BA=90=E4=B8=8A=E4=BC=A0=E8=A2=AB=E8=B7=A8=E5=9F=9F?= =?UTF-8?q?=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/__tests__/cors-origin.test.ts | 123 ++++++++++++++++++++++++++++++ lib/cors-origin.ts | 106 +++++++++++++++++++++++++ middleware.ts | 24 ++---- 3 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 lib/__tests__/cors-origin.test.ts create mode 100644 lib/cors-origin.ts diff --git a/lib/__tests__/cors-origin.test.ts b/lib/__tests__/cors-origin.test.ts new file mode 100644 index 0000000..9e883c5 --- /dev/null +++ b/lib/__tests__/cors-origin.test.ts @@ -0,0 +1,123 @@ +/** @jest-environment node */ + +import { + getAllowedCorsOrigins, + getRequestHostOrigin, + isAllowedCorsOrigin, +} from '@/lib/cors-origin' + +const originalAppPublicUrl = process.env.APP_PUBLIC_URL +const originalNextAuthUrl = process.env.NEXTAUTH_URL +const originalCorsAllowedOrigins = process.env.CORS_ALLOWED_ORIGINS +const originalNextPublicAppUrl = process.env.NEXT_PUBLIC_APP_PUBLIC_URL + +function createRequest(headers: Record, nextUrlOrigin = 'http://127.0.0.1:3000') { + return { + headers: { + get(name: string) { + return headers[name.toLowerCase()] ?? null + }, + }, + nextUrl: { + origin: nextUrlOrigin, + protocol: new URL(nextUrlOrigin).protocol, + }, + url: `${nextUrlOrigin}/api/work-review/projects`, + } +} + +describe('cors origin helpers', () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_APP_PUBLIC_URL = 'https://sc.jiansun.vip' + process.env.APP_PUBLIC_URL = 'https://sc.jiansun.vip' + process.env.NEXTAUTH_URL = 'https://sc.jiansun.vip' + process.env.CORS_ALLOWED_ORIGINS = '' + }) + + afterAll(() => { + if (originalNextPublicAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_PUBLIC_URL + else process.env.NEXT_PUBLIC_APP_PUBLIC_URL = originalNextPublicAppUrl + + if (originalAppPublicUrl === undefined) delete process.env.APP_PUBLIC_URL + else process.env.APP_PUBLIC_URL = originalAppPublicUrl + + if (originalNextAuthUrl === undefined) delete process.env.NEXTAUTH_URL + else process.env.NEXTAUTH_URL = originalNextAuthUrl + + if (originalCorsAllowedOrigins === undefined) delete process.env.CORS_ALLOWED_ORIGINS + else process.env.CORS_ALLOWED_ORIGINS = originalCorsAllowedOrigins + }) + + test('prefers forwarded public host origin when nextUrl resolves to an internal address', () => { + const request = createRequest({ + host: 'sc.jiansun.vip', + 'x-forwarded-proto': 'https', + origin: 'https://sc.jiansun.vip', + }) + + expect(getRequestHostOrigin(request)).toBe('https://sc.jiansun.vip') + expect(Array.from(getAllowedCorsOrigins(request))).toContain('https://sc.jiansun.vip') + expect(isAllowedCorsOrigin(request, 'https://sc.jiansun.vip')).toBe(true) + }) + + test('allows trusted intranet entry origins when host headers point to the intranet domain', () => { + const request = createRequest( + { + host: '192.168.58.20:4320', + 'x-forwarded-proto': 'http', + origin: 'http://192.168.58.20:4320', + }, + 'https://sc.jiansun.vip', + ) + + expect(getRequestHostOrigin(request)).toBe('http://192.168.58.20:4320') + expect(isAllowedCorsOrigin(request, 'http://192.168.58.20:4320')).toBe(true) + }) + + test('keeps the public sc.jiansun.vip origin allowed when production only exposes an intranet NEXTAUTH_URL', () => { + delete process.env.NEXT_PUBLIC_APP_PUBLIC_URL + delete process.env.APP_PUBLIC_URL + process.env.NEXTAUTH_URL = 'http://192.168.58.20:4320' + process.env.CORS_ALLOWED_ORIGINS = '' + + const request = createRequest( + { + host: '192.168.58.20:4320', + 'x-forwarded-proto': 'http', + origin: 'https://sc.jiansun.vip', + }, + 'http://192.168.58.20:4320', + ) + + expect(Array.from(getAllowedCorsOrigins(request))).toContain('https://sc.jiansun.vip') + expect(isAllowedCorsOrigin(request, 'https://sc.jiansun.vip')).toBe(true) + }) + + test('rejects foreign origins that do not match the request host or configured whitelist', () => { + const request = createRequest({ + host: 'sc.jiansun.vip', + 'x-forwarded-proto': 'https', + origin: 'https://evil.example.com', + }) + + expect(isAllowedCorsOrigin(request, 'https://evil.example.com')).toBe(false) + }) + + test('does not re-open the retired legacy public domain unless it is explicitly configured', () => { + delete process.env.NEXT_PUBLIC_APP_PUBLIC_URL + delete process.env.APP_PUBLIC_URL + process.env.NEXTAUTH_URL = 'http://192.168.58.20:4320' + process.env.CORS_ALLOWED_ORIGINS = '' + + const request = createRequest( + { + host: '192.168.58.20:4320', + 'x-forwarded-proto': 'http', + origin: 'https://3d.yydd.ai', + }, + 'http://192.168.58.20:4320', + ) + + expect(isAllowedCorsOrigin(request, 'https://3d.yydd.ai')).toBe(false) + }) +}) diff --git a/lib/cors-origin.ts b/lib/cors-origin.ts new file mode 100644 index 0000000..344016c --- /dev/null +++ b/lib/cors-origin.ts @@ -0,0 +1,106 @@ +import { getPublicAppOrigin } from '@/lib/public-app-url' + +type HeaderAccessor = { + get?: (name: string) => string | null | undefined +} + +type RequestLike = { + headers?: HeaderAccessor + nextUrl?: { + origin?: string + protocol?: string + } + url?: string +} + +function getFirstHeaderValue(headersLike: HeaderAccessor | undefined, name: string): string { + const raw = headersLike?.get?.(name) || '' + return raw.split(',')[0].trim() +} + +function normalizeProtocol(value: string | null | undefined): 'http' | 'https' | null { + const normalized = String(value || '').trim().replace(/:$/, '').toLowerCase() + if (normalized === 'http' || normalized === 'https') { + return normalized + } + return null +} + +function resolveForwardedProtocol(headersLike: HeaderAccessor | undefined): 'http' | 'https' | null { + const forwardedProto = normalizeProtocol(getFirstHeaderValue(headersLike, 'x-forwarded-proto')) + if (forwardedProto) { + return forwardedProto + } + + const forwardedSsl = getFirstHeaderValue(headersLike, 'x-forwarded-ssl').toLowerCase() + if (forwardedSsl === 'on') { + return 'https' + } + + const forwardedPort = getFirstHeaderValue(headersLike, 'x-forwarded-port') + if (forwardedPort === '443') { + return 'https' + } + + return null +} + +export function normalizeHttpOrigin(value: unknown): string | null { + const raw = String(value ?? '').trim() + if (!raw) return null + + try { + const parsed = new URL(raw) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) { + return null + } + return parsed.origin + } catch { + return null + } +} + +export function getRequestHostOrigin(request: RequestLike): string | null { + const host = + getFirstHeaderValue(request.headers, 'host') || + getFirstHeaderValue(request.headers, 'x-forwarded-host') + if (!host) { + return null + } + + const protocol = + resolveForwardedProtocol(request.headers) || + normalizeProtocol(request.nextUrl?.protocol) || + normalizeProtocol(request.url ? new URL(request.url).protocol : null) || + 'https' + + return normalizeHttpOrigin(`${protocol}://${host}`) +} + +export function getAllowedCorsOrigins(request: RequestLike): Set { + const configuredOrigins = String(process.env.CORS_ALLOWED_ORIGINS || '') + .split(',') + .map((item) => normalizeHttpOrigin(item)) + .filter((item): item is string => Boolean(item)) + + const values = [ + getPublicAppOrigin(), + getRequestHostOrigin(request), + normalizeHttpOrigin(request.nextUrl?.origin), + normalizeHttpOrigin(process.env.APP_PUBLIC_URL), + normalizeHttpOrigin(process.env.NEXTAUTH_URL), + ...configuredOrigins, + ] + + return new Set(values.filter((item): item is string => Boolean(item))) +} + +export function isAllowedCorsOrigin(request: RequestLike, requestOrigin: string | null): boolean { + if (!requestOrigin) return true + const normalizedRequestOrigin = normalizeHttpOrigin(requestOrigin) + if (!normalizedRequestOrigin) return false + return getAllowedCorsOrigins(request).has(normalizedRequestOrigin) +} diff --git a/middleware.ts b/middleware.ts index e8d8ce0..6824cc0 100644 --- a/middleware.ts +++ b/middleware.ts @@ -6,6 +6,7 @@ import { resolveSecureAuthCookieFromRequest, } from '@/lib/auth-cookie' import { shouldRedirectToStandaloneMaterialLibrary } from '@/lib/brand-center-access-policy' +import { getAllowedCorsOrigins, isAllowedCorsOrigin } from '@/lib/cors-origin' const API_CORS_ALLOWED_HEADERS = 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie' @@ -19,22 +20,6 @@ function isDepartmentPage(pathname: string): boolean { return pathname === '/' || DEPARTMENT_PAGE_PREFIXES.some((prefix) => pathname.startsWith(prefix)) } -function getAllowedCorsOrigins(request: NextRequest): Set { - const values = [ - request.nextUrl.origin, - process.env.APP_PUBLIC_URL, - process.env.NEXTAUTH_URL, - ...(process.env.CORS_ALLOWED_ORIGINS || '').split(','), - ] - - return new Set(values.map((value) => String(value || '').trim().replace(/\/+$/, '')).filter(Boolean)) -} - -function isAllowedCorsOrigin(request: NextRequest, requestOrigin: string | null): boolean { - if (!requestOrigin) return true - return getAllowedCorsOrigins(request).has(requestOrigin.replace(/\/+$/, '')) -} - // 注意:不在middleware中启动内存监控 // middleware使用Edge Runtime,不支持Node.js API // 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境) @@ -52,6 +37,13 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/api/')) { const corsOriginAllowed = isAllowedCorsOrigin(request, requestOrigin) if (!corsOriginAllowed) { + console.warn('[middleware] blocked api origin', { + pathname, + requestOrigin, + requestHostOrigin: request.headers.get('host') || request.headers.get('x-forwarded-host') || '', + requestProto: request.headers.get('x-forwarded-proto') || request.nextUrl.protocol || '', + allowedOrigins: Array.from(getAllowedCorsOrigins(request)), + }) return NextResponse.json({ error: '不允许的跨域来源' }, { status: 403 }) }