diff --git a/web/__tests__/proxy-frame-options.spec.ts b/web/__tests__/proxy-frame-options.spec.ts index 4b6da4cf79a..6510dedf113 100644 --- a/web/__tests__/proxy-frame-options.spec.ts +++ b/web/__tests__/proxy-frame-options.spec.ts @@ -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', () => { diff --git a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts index ea38a4419ea..ce797dc63dd 100644 --- a/web/app/signin/utils/__tests__/post-login-redirect.spec.ts +++ b/web/app/signin/utils/__tests__/post-login-redirect.spec.ts @@ -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()), - 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[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', }) }) }) diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index ef9b80668e6..fc4ecaaca43 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -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> = { '/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 diff --git a/web/proxy.ts b/web/proxy.ts index 16ba4384a23..1f7248efd53 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -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';