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:
CodingOnStar
2026-08-14 12:38:03 +08:00
parent 3785891102
commit cc06433dd3
4 changed files with 42 additions and 25 deletions
+24 -1
View File
@@ -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
View File
@@ -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';