diff --git a/app/api/dingtalk/session-ticket/route.ts b/app/api/dingtalk/session-ticket/route.ts index 8695ac9..6820332 100644 --- a/app/api/dingtalk/session-ticket/route.ts +++ b/app/api/dingtalk/session-ticket/route.ts @@ -6,7 +6,7 @@ import { deleteDingtalkLoginHandoff, DINGTALK_LOGIN_HANDOFF_COOKIE, } from '@/lib/dingtalk-login-handoff' -import { getTrustedDingtalkReturnOrigin } from '@/lib/dingtalk-return-origin' +import { getTrustedDingtalkReturnOriginFromRequest } from '@/lib/dingtalk-return-origin' import { setDingtalkAuthenticatedSessionCookies } from '@/lib/dingtalk-login' import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie' @@ -266,8 +266,13 @@ async function resolveActiveTicketUser(userId: string) { } export async function GET(request: NextRequest) { - const requestOrigin = request.nextUrl.origin + const requestOrigin = getTrustedDingtalkReturnOriginFromRequest(request) const handoffBinding = request.cookies.get(DINGTALK_LOGIN_HANDOFF_COOKIE)?.value || null + + if (!requestOrigin) { + return createErrorPage('登录交接来源校验失败,请重新登录。') + } + let ticket try { @@ -284,7 +289,7 @@ export async function GET(request: NextRequest) { return createErrorPage('登录交接已失效或不是当前浏览器发起,请重新登录。') } - if (ticket.targetOrigin && getTrustedDingtalkReturnOrigin(ticket.targetOrigin) !== requestOrigin) { + if (ticket.targetOrigin && ticket.targetOrigin !== requestOrigin) { return createErrorPage('登录交接来源校验失败,请重新登录。') } diff --git a/app/api/dingtalk/session-transfer/start/route.test.ts b/app/api/dingtalk/session-transfer/start/route.test.ts index 0018eee..b4eaf78 100644 --- a/app/api/dingtalk/session-transfer/start/route.test.ts +++ b/app/api/dingtalk/session-transfer/start/route.test.ts @@ -30,15 +30,20 @@ describe('POST /api/dingtalk/session-transfer/start', () => { }) test('仅在受信内网入口创建绑定 Cookie 与交接 ID', async () => { - const response = await POST(new NextRequest('http://192.168.58.20:4320/api/dingtalk/session-transfer/start', { + const response = await POST(new NextRequest('https://sc.jiansun.vip/api/dingtalk/session-transfer/start', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + Host: '192.168.58.20:4320', + 'X-Forwarded-Proto': 'http', + }, body: JSON.stringify({ redirectPath: '/admin?tab=users' }), })) const data = await response.json() expect(data).toMatchObject({ success: true, enabled: true }) expect(response.headers.get('set-cookie')).toContain('dingtalk-login-handoff=') + expect(response.headers.get('set-cookie')).not.toMatch(/;\s*Secure(?:;|$)/i) const stored = await getDingtalkLoginHandoff(data.handoffId) expect(stored).toMatchObject({ diff --git a/app/api/dingtalk/session-transfer/start/route.ts b/app/api/dingtalk/session-transfer/start/route.ts index c25d47f..d98c7f5 100644 --- a/app/api/dingtalk/session-transfer/start/route.ts +++ b/app/api/dingtalk/session-transfer/start/route.ts @@ -3,7 +3,7 @@ import { createDingtalkLoginHandoff, DINGTALK_LOGIN_HANDOFF_COOKIE, } from '@/lib/dingtalk-login-handoff' -import { getTrustedDingtalkReturnOrigin } from '@/lib/dingtalk-return-origin' +import { getTrustedDingtalkReturnOriginFromRequest } from '@/lib/dingtalk-return-origin' import { normalizeInternalRedirectPath } from '@/lib/safe-redirect' import { resolveSecureAuthCookieFromRequest } from '@/lib/auth-cookie' @@ -11,8 +11,7 @@ export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export async function POST(request: NextRequest) { - const requestOrigin = request.nextUrl.origin - const trustedOrigin = getTrustedDingtalkReturnOrigin(requestOrigin) + const trustedOrigin = getTrustedDingtalkReturnOriginFromRequest(request) // 公网登录不需要、也不能创建内网回跳票据;保持既有的公网登录流程。 if (!trustedOrigin) { diff --git a/lib/__tests__/dingtalk-return-origin.test.ts b/lib/__tests__/dingtalk-return-origin.test.ts index b50a75e..87a24b0 100644 --- a/lib/__tests__/dingtalk-return-origin.test.ts +++ b/lib/__tests__/dingtalk-return-origin.test.ts @@ -3,6 +3,7 @@ import { getDingtalkReturnOrigins, getTrustedDingtalkReturnOrigin, + getTrustedDingtalkReturnOriginFromRequest, } from '@/lib/dingtalk-return-origin' describe('DingTalk internal return origin allowlist', () => { @@ -32,4 +33,33 @@ describe('DingTalk internal return origin allowlist', () => { expect(getTrustedDingtalkReturnOrigin('http://192.168.58.20.evil.test:4320')).toBeNull() expect(getTrustedDingtalkReturnOrigin('https://sc.jiansun.vip')).toBeNull() }) + + test('框架 URL 被公网基地址覆盖时,仍按代理传入的内网 Host 识别来源', () => { + process.env.DINGTALK_INTERNAL_RETURN_ORIGINS = 'http://192.168.58.20:4320' + + expect(getTrustedDingtalkReturnOriginFromRequest({ + headers: new Headers({ + host: '192.168.58.20:4320', + 'x-forwarded-proto': 'http', + }), + nextUrl: { origin: 'https://sc.jiansun.vip' }, + })).toBe('http://192.168.58.20:4320') + + expect(getTrustedDingtalkReturnOriginFromRequest({ + headers: new Headers({ + host: 'sc.jiansun.vip', + 'x-forwarded-proto': 'https', + }), + nextUrl: { origin: 'https://sc.jiansun.vip' }, + })).toBeNull() + + expect(getTrustedDingtalkReturnOriginFromRequest({ + headers: new Headers({ + host: 'sc.jiansun.vip', + 'x-forwarded-host': '192.168.58.20:4320', + 'x-forwarded-proto': 'http', + }), + nextUrl: { origin: 'https://sc.jiansun.vip' }, + })).toBeNull() + }) }) diff --git a/lib/dingtalk-return-origin.ts b/lib/dingtalk-return-origin.ts index 7789b50..6df3260 100644 --- a/lib/dingtalk-return-origin.ts +++ b/lib/dingtalk-return-origin.ts @@ -1,5 +1,14 @@ 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 @@ -27,6 +36,20 @@ function parseConfiguredOrigins(value: string | undefined): string[] { .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 完成后允许交接会话的内网入口。 * @@ -45,3 +68,22 @@ export function getTrustedDingtalkReturnOrigin(value: unknown): string | 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) +}