90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
const DEFAULT_DINGTALK_RETURN_ORIGINS = ['http://192.168.58.20:4320']
|
||
|
||
type DingtalkRequestLike = {
|
||
headers?: {
|
||
get?: (name: string) => string | null | undefined
|
||
}
|
||
nextUrl?: {
|
||
origin?: string
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
function parseConfiguredOrigins(value: string | undefined): string[] {
|
||
return String(value || '')
|
||
.split(',')
|
||
.map((item) => normalizeHttpOrigin(item))
|
||
.filter((item): item is string => Boolean(item))
|
||
}
|
||
|
||
function getFirstHeaderValue(request: DingtalkRequestLike, name: string): string {
|
||
const raw = request.headers?.get?.(name) || ''
|
||
return raw.split(',')[0].trim()
|
||
}
|
||
|
||
function getProtocolFromForwardedHeader(request: DingtalkRequestLike): 'http' | 'https' | null {
|
||
const protocol = getFirstHeaderValue(request, 'x-forwarded-proto').replace(/:$/, '').toLowerCase()
|
||
if (protocol === 'http' || protocol === 'https') {
|
||
return protocol
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 钉钉必须回到公网完成 OAuth;这里仅定义 OAuth 完成后允许交接会话的内网入口。
|
||
*
|
||
* 生产环境通过 DINGTALK_INTERNAL_RETURN_ORIGINS 显式配置。未配置时仅保留当前稳定内网入口,
|
||
* 防止把 state 中任意地址当成跳转目标。
|
||
*/
|
||
export function getDingtalkReturnOrigins(): string[] {
|
||
const configured = parseConfiguredOrigins(process.env.DINGTALK_INTERNAL_RETURN_ORIGINS)
|
||
const candidates = configured.length > 0 ? configured : DEFAULT_DINGTALK_RETURN_ORIGINS
|
||
return Array.from(new Set(candidates))
|
||
}
|
||
|
||
export function getTrustedDingtalkReturnOrigin(value: unknown): string | null {
|
||
const origin = normalizeHttpOrigin(value)
|
||
if (!origin) return null
|
||
|
||
return getDingtalkReturnOrigins().includes(origin) ? origin : null
|
||
}
|
||
|
||
/**
|
||
* Next.js 在设置 NEXTAUTH_URL 后可能把 request.nextUrl 固定解析为公网地址。
|
||
* 内网交接必须优先使用反向代理实际传入的 Host + Protocol,再做精确白名单校验。
|
||
*/
|
||
export function getTrustedDingtalkReturnOriginFromRequest(request: DingtalkRequestLike): string | null {
|
||
// 本地代理明确保留 Host;X-Forwarded-Host 只作为没有 Host 时的兼容兜底,
|
||
// 避免把客户端可伪造的转发头当成首选来源。
|
||
const host = getFirstHeaderValue(request, 'host') || getFirstHeaderValue(request, 'x-forwarded-host')
|
||
if (host) {
|
||
const protocol = getProtocolFromForwardedHeader(request) || 'http'
|
||
const trustedOrigin = getTrustedDingtalkReturnOrigin(`${protocol}://${host}`)
|
||
if (trustedOrigin) {
|
||
return trustedOrigin
|
||
}
|
||
}
|
||
|
||
return getTrustedDingtalkReturnOrigin(request.nextUrl?.origin)
|
||
}
|