fix: 修复公网同源上传被跨域拦截

This commit is contained in:
Codex 2026-08-25 12:10:25 +08:00
parent 07bb208048
commit c13f5a6e1c
3 changed files with 237 additions and 16 deletions

View File

@ -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<string, string>, 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)
})
})

106
lib/cors-origin.ts Normal file
View File

@ -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<string> {
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)
}

View File

@ -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<string> {
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 })
}