fix: 识别钉钉内网登录来源

This commit is contained in:
chenpei 2026-07-17 20:16:05 +08:00
parent 553e20c37e
commit 8fd62384d2
5 changed files with 89 additions and 8 deletions

View File

@ -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('登录交接来源校验失败,请重新登录。')
}

View File

@ -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({

View File

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

View File

@ -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()
})
})

View File

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