mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
fix(web): stop allowing Marketplace origin as a post-login redirect
Marketplace login returns to the same-origin authorize URL, so the Marketplace prefix does not belong on the post-login allowlist. Also add that origin to frame-src so the detail iframe is not blocked when a CSP whitelist is enabled.
This commit is contained in:
@@ -4,6 +4,7 @@ import { canEmbedPath, proxy } from '@/proxy'
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
NEXT_PUBLIC_ALLOW_EMBED: false,
|
||||
NEXT_PUBLIC_CSP_WHITELIST: 'https://example.com',
|
||||
NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: '',
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY: '',
|
||||
}))
|
||||
|
||||
@@ -24,6 +25,7 @@ const createRequest = (url: string) => {
|
||||
describe('proxy frame options', () => {
|
||||
afterEach(() => {
|
||||
mockEnv.NEXT_PUBLIC_ALLOW_EMBED = false
|
||||
mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = ''
|
||||
mockEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY = ''
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
@@ -90,13 +92,34 @@ describe('proxy frame options', () => {
|
||||
it('should deny framing for the Marketplace OAuth authorize route', () => {
|
||||
const response = proxy(
|
||||
createRequest(
|
||||
'https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client&flow=marketplace',
|
||||
'https://cloud.dify.ai/account/oauth/authorize?client_id=marketplace-client',
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.headers.get('x-frame-options')).toBe('DENY')
|
||||
expect(response.headers.get('content-security-policy')).toContain("frame-ancestors 'none'")
|
||||
})
|
||||
|
||||
it('should allow framing Marketplace pages when a Marketplace origin is configured', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production')
|
||||
mockEnv.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX = 'https://marketplace.dify.ai'
|
||||
|
||||
const response = proxy(createRequest('https://cloud.dify.ai/marketplace'))
|
||||
|
||||
expect(response.headers.get('content-security-policy') ?? '').toMatch(
|
||||
/frame-src[^;]*https:\/\/marketplace\.dify\.ai/,
|
||||
)
|
||||
})
|
||||
|
||||
it('should not add a Marketplace frame origin when the prefix is unset', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production')
|
||||
|
||||
const response = proxy(createRequest('https://cloud.dify.ai/marketplace'))
|
||||
const contentSecurityPolicy = response.headers.get('content-security-policy') ?? ''
|
||||
|
||||
expect(contentSecurityPolicy).toContain('frame-src')
|
||||
expect(contentSecurityPolicy).not.toContain('https://marketplace.dify.ai')
|
||||
})
|
||||
})
|
||||
|
||||
describe('proxy education entry normalization', () => {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolvePostLoginRedirect, setPostLoginRedirect } from '../post-login-redirect'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/config')>()),
|
||||
MARKETPLACE_URL_PREFIX: 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
describe('post-login redirect utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -53,20 +48,9 @@ describe('post-login redirect utilities', () => {
|
||||
).toEqual({ kind: 'absolute', href: redirectUrl })
|
||||
})
|
||||
|
||||
it('should allow an absolute target on the configured Marketplace origin', () => {
|
||||
const redirectUrl = 'http://localhost:3000/plugin/langgenius/openai?tab=reviews#rating'
|
||||
const searchParams = new URLSearchParams({ redirect_url: redirectUrl })
|
||||
|
||||
expect(
|
||||
resolvePostLoginRedirect(
|
||||
searchParams as unknown as Parameters<typeof resolvePostLoginRedirect>[0],
|
||||
),
|
||||
).toEqual({ kind: 'absolute', href: redirectUrl })
|
||||
})
|
||||
|
||||
it('should reject a target that only resembles the configured Marketplace origin', () => {
|
||||
it('should reject a Marketplace origin that is not a trusted Dify login target', () => {
|
||||
const searchParams = new URLSearchParams({
|
||||
redirect_url: 'http://localhost:3002/plugin/langgenius/openai',
|
||||
redirect_url: 'http://localhost:3001/plugin/langgenius/openai?tab=reviews#rating',
|
||||
})
|
||||
|
||||
expect(
|
||||
@@ -140,12 +124,12 @@ describe('post-login redirect utilities', () => {
|
||||
|
||||
it('should preserve every Marketplace OAuth authorize parameter across signin', () => {
|
||||
setPostLoginRedirect(
|
||||
'/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code&flow=marketplace',
|
||||
'/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code',
|
||||
)
|
||||
|
||||
expect(resolvePostLoginRedirect()).toEqual({
|
||||
kind: 'internal',
|
||||
href: '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code&flow=marketplace',
|
||||
href: '/account/oauth/authorize?client_id=marketplace-client&redirect_uri=https%3A%2F%2Fapi.marketplace.dify.ai%2Fapi%2Fv1%2Fauth%2Fcallback%2Fdify&state=oauth-state&response_type=code',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ReadonlyURLSearchParams } from '@/next/navigation'
|
||||
import { MARKETPLACE_URL_PREFIX } from '@/config'
|
||||
import { getClientLoginFallback, resolveLoginRedirectTarget } from '@/utils/login-redirect'
|
||||
|
||||
const REDIRECT_URL_KEY = 'redirect_url'
|
||||
@@ -10,7 +9,6 @@ const ALLOWED: Record<string, ReadonlySet<string>> = {
|
||||
'/device': new Set(['user_code', 'sso_verified']),
|
||||
'/account/oauth/authorize': new Set([
|
||||
'client_id',
|
||||
'flow',
|
||||
'redirect_uri',
|
||||
'response_type',
|
||||
'scope',
|
||||
@@ -87,7 +85,6 @@ export function resolvePostLoginRedirect(searchParams?: ReadonlyURLSearchParams)
|
||||
if (searchParams?.has(REDIRECT_URL_KEY)) {
|
||||
return (
|
||||
resolveLoginRedirectTarget(searchParams.get(REDIRECT_URL_KEY), {
|
||||
allowedAbsoluteOrigins: MARKETPLACE_URL_PREFIX ? [MARKETPLACE_URL_PREFIX] : [],
|
||||
allowSameOriginAbsolute: true,
|
||||
currentOrigin,
|
||||
}) ?? fallback
|
||||
|
||||
+14
-1
@@ -21,6 +21,17 @@ const NON_EMBEDDABLE_PATH_SEGMENTS = ['/device']
|
||||
const FRAME_ANCESTORS_NONE = "'none'"
|
||||
const LEGACY_EDUCATION_ACTION = 'getEducationVerify'
|
||||
|
||||
const getHttpOrigin = (value: string | undefined) => {
|
||||
if (!value) return ''
|
||||
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const matchesPathSegment = (pathname: string, segments: string[]) =>
|
||||
segments.some((segment) => pathname === segment || pathname.startsWith(`${segment}/`))
|
||||
|
||||
@@ -83,6 +94,8 @@ export function proxy(request: NextRequest) {
|
||||
? ' https://challenges.cloudflare.com'
|
||||
: ''
|
||||
const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}${turnstileOrigin}`
|
||||
const marketplaceFrameOrigin = getHttpOrigin(env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX)
|
||||
const marketplaceFrameSrc = marketplaceFrameOrigin ? ` ${marketplaceFrameOrigin}` : ''
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
|
||||
const csp = `'nonce-${nonce}'`
|
||||
|
||||
@@ -95,7 +108,7 @@ export function proxy(request: NextRequest) {
|
||||
style-src 'self' 'unsafe-inline' ${scheme_source} ${whiteList};
|
||||
worker-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
media-src 'self' ${scheme_source} ${csp} ${whiteList};
|
||||
frame-src 'self' ${scheme_source} ${whiteList};
|
||||
frame-src 'self' ${scheme_source} ${whiteList}${marketplaceFrameSrc};
|
||||
img-src * data: blob:;
|
||||
font-src 'self';
|
||||
object-src 'none';
|
||||
|
||||
Reference in New Issue
Block a user