fix: 修复公网同源上传被跨域拦截
This commit is contained in:
parent
07bb208048
commit
c13f5a6e1c
123
lib/__tests__/cors-origin.test.ts
Normal file
123
lib/__tests__/cors-origin.test.ts
Normal 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
106
lib/cors-origin.ts
Normal 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)
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user