fix: 识别钉钉内网登录来源
This commit is contained in:
parent
553e20c37e
commit
8fd62384d2
@ -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('登录交接来源校验失败,请重新登录。')
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user