136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
import { isTrustedOrigin, normalizeOrigin } from '~~/server/utils/request-utils'
|
|
|
|
export default defineEventHandler((event) => {
|
|
const requestUrl = getRequestURL(event)
|
|
const pathname = requestUrl.pathname
|
|
const method = getMethod(event)
|
|
|
|
// 只处理特定的内部API路由,防止站外调用
|
|
const isProtectedApi = pathname.startsWith('/api/api-enhanced/netease') ||
|
|
pathname.startsWith('/api/native-api')
|
|
|
|
if (!isProtectedApi) {
|
|
return
|
|
}
|
|
|
|
// 内部受信客户端绕过 CORS 校验
|
|
const requestedFrom = getHeader(event, 'x-requested-from')
|
|
if (requestedFrom === 'ClassIslandPlugin') {
|
|
return
|
|
}
|
|
|
|
// 显式配置 NUXT_PUBLIC_HOST 时使用配置来源校验。
|
|
// 未配置时仍使用 Host 头;云平台等公开 Host 继续校验,仅当反向代理把 Host 改写为回环地址时跳过。
|
|
const config = useRuntimeConfig(event)
|
|
let configuredHost = typeof config.public?.host === 'string' ? config.public.host.trim() : ''
|
|
if (!configuredHost) {
|
|
const hostHeader = getHeader(event, 'x-forwarded-host') || getHeader(event, 'host')
|
|
if (!hostHeader) {
|
|
throw createError({ statusCode: 400, message: 'Bad Request: 缺少Host请求头' })
|
|
}
|
|
|
|
configuredHost = (hostHeader.split(',')[0] || '').trim()
|
|
|
|
try {
|
|
const hostOrigin = normalizeOrigin(configuredHost, requestUrl.protocol)
|
|
if (isLoopbackHostname(hostOrigin.hostname)) {
|
|
return
|
|
}
|
|
} catch {
|
|
throw createError({ statusCode: 400, message: 'Bad Request: Host请求头无效' })
|
|
}
|
|
|
|
// 如果 x-forwarded-proto 存在,可以带上协议,使校验更精准
|
|
const forwardedProto = getHeader(event, 'x-forwarded-proto')
|
|
if (forwardedProto && !configuredHost.includes('://')) {
|
|
const proto = (forwardedProto.split(',')[0] || '').trim()
|
|
configuredHost = `${proto}://${configuredHost}`
|
|
}
|
|
}
|
|
|
|
// CORS 限制:禁止站外网站调用
|
|
const origin = getHeader(event, 'origin')
|
|
const referer = getHeader(event, 'referer')
|
|
const secFetchSite = getHeader(event, 'sec-fetch-site')
|
|
const secFetchMode = getHeader(event, 'sec-fetch-mode')
|
|
const sourceUrl = origin || referer
|
|
|
|
// Fetch Metadata 由浏览器生成且脚本无法修改,同源请求不应被部署平台改写的 Host 误伤。
|
|
if (secFetchSite === 'same-origin') return
|
|
|
|
if (sourceUrl) {
|
|
try {
|
|
const sourceOrigin = normalizeOrigin(sourceUrl, requestUrl.protocol)
|
|
const trustedOrigin = normalizeOrigin(configuredHost, requestUrl.protocol)
|
|
const isSameLoopbackOrigin =
|
|
isLoopbackHostname(sourceOrigin.hostname) &&
|
|
isLoopbackHostname(trustedOrigin.hostname) &&
|
|
sourceOrigin.protocol === trustedOrigin.protocol &&
|
|
sourceOrigin.port === trustedOrigin.port
|
|
|
|
const matchesConfiguredOrigin = isTrustedOrigin(sourceOrigin, trustedOrigin)
|
|
const requestHost = getHeader(event, 'x-forwarded-host') || getHeader(event, 'host')
|
|
const requestProto = getHeader(event, 'x-forwarded-proto')?.split(',')[0]?.trim() || requestUrl.protocol.replace(':', '')
|
|
const hostOrigin = requestHost
|
|
? normalizeOrigin(`${requestProto}://${requestHost.split(',')[0].trim()}`, requestUrl.protocol)
|
|
: null
|
|
const matchesRequestHost = hostOrigin && isTrustedOrigin(sourceOrigin, hostOrigin)
|
|
|
|
if (!matchesConfiguredOrigin && !matchesRequestHost && !isSameLoopbackOrigin) {
|
|
console.warn(`[CORS Middleware] 拦截跨域请求: 来源 ${sourceOrigin.origin}, 期望 ${trustedOrigin.origin}, 路径 ${pathname}`)
|
|
throw createError({
|
|
statusCode: 403,
|
|
message: 'Forbidden: 内部API不允许跨域请求'
|
|
})
|
|
}
|
|
} catch (e: unknown) {
|
|
if (
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
'statusCode' in e &&
|
|
typeof e.statusCode === 'number'
|
|
) {
|
|
throw e
|
|
}
|
|
console.warn(`[CORS Middleware] 无效的来源 URL: ${sourceUrl}`, e)
|
|
throw createError({
|
|
statusCode: 400,
|
|
message: 'Bad Request: Origin或Referer头无效'
|
|
})
|
|
}
|
|
} else if (isTrustedFetchMetadata(secFetchSite, secFetchMode, method)) {
|
|
// 浏览器隐私策略可能移除来源头,此时 Fetch Metadata 是更稳定的同源信号。
|
|
return
|
|
} else {
|
|
// 没有来源信息且缺少可信 Fetch Metadata 时,仍按站外请求处理。
|
|
console.warn(`[CORS Middleware] 拦截无Origin/Referer头的请求: 路径 ${pathname}, sec-fetch-site: ${secFetchSite || 'missing'}`)
|
|
throw createError({
|
|
statusCode: 403,
|
|
message: 'Forbidden: 访问此API必须提供Origin或Referer头'
|
|
})
|
|
}
|
|
})
|
|
|
|
function isLoopbackHostname(hostname: string): boolean {
|
|
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
|
|
}
|
|
|
|
function isTrustedFetchMetadata(
|
|
secFetchSite: string | undefined,
|
|
secFetchMode: string | undefined,
|
|
method: string
|
|
): boolean {
|
|
const normalizedMethod = method.toUpperCase()
|
|
const safeMethod = normalizedMethod === 'GET' || normalizedMethod === 'HEAD'
|
|
|
|
if (secFetchSite === 'same-origin') return true
|
|
|
|
if (secFetchSite === 'none') {
|
|
const isNavigation = !secFetchMode || secFetchMode === 'navigate'
|
|
return safeMethod && isNavigation
|
|
}
|
|
|
|
// 兼容不发送 Fetch Metadata 的老浏览器、WebView 或反向代理;跨站请求有明确标记时仍会被拒绝。
|
|
return !secFetchSite && safeMethod
|
|
}
|