From df8824e16c4163572766f34486842dd903857217 Mon Sep 17 00:00:00 2001 From: Liam Neville Date: Fri, 20 Feb 2026 15:20:02 +0300 Subject: [PATCH] fix(login): CSP img-src resolved at build time instead of runtime (#11603) # Which Problems Are Solved - On self-hosted ZITADEL, the CSP `img-src` directive was baked in at build time from `ZITADEL_API_URL`, blocking branding logos when the runtime domain differs from the build environment - CSP construction was duplicated across three files with no single source of truth # How the Problems Are Solved - Introduced a centralized `buildCSP()` function in `src/lib/csp.ts` that constructs the full CSP string from runtime inputs (`serviceUrl` for img-src, `iframeOrigins` for frame-ancestors) - Middleware and flow-initiation now call `buildCSP()` instead of concatenating strings against a static `DEFAULT_CSP` - `constants/csp.js` is simplified to a static fallback used only by `next.config.mjs` # Additional Changes - Removed no-op `securitySettings.embeddedIframe.allowedOrigins;` statements in flow-initiation - Removed duplicate `X-Frame-Options: SAMEORIGIN` header from `next.config.mjs` (conflicted with `X-Frame-Options: deny`) - Added `X-Frame-Options: deny` explicitly on proxy route responses in middleware (previously relied on next.config.mjs static header surviving the rewrite) # Additional Context - Closes #11088 - Closes #11602 --- apps/login/constants/csp.js | 6 +- apps/login/next.config.mjs | 6 +- apps/login/src/lib/csp.test.ts | 63 ++++++++++++++++++++ apps/login/src/lib/csp.ts | 36 +++++++++++ apps/login/src/lib/server/flow-initiation.ts | 43 +++++++------ apps/login/src/middleware.ts | 17 +++--- 6 files changed, 135 insertions(+), 36 deletions(-) create mode 100644 apps/login/src/lib/csp.test.ts create mode 100644 apps/login/src/lib/csp.ts diff --git a/apps/login/constants/csp.js b/apps/login/constants/csp.js index dc3dc1e05..b48856397 100644 --- a/apps/login/constants/csp.js +++ b/apps/login/constants/csp.js @@ -1,6 +1,2 @@ -const ZITADEL_DOMAIN = process.env.ZITADEL_API_URL - ? new URL(process.env.ZITADEL_API_URL).hostname - : '*.zitadel.cloud'; - export const DEFAULT_CSP = - `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://${ZITADEL_DOMAIN};`; + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self'; frame-ancestors 'none'; object-src 'none'"; diff --git a/apps/login/next.config.mjs b/apps/login/next.config.mjs index 5e85d3231..0ee67d5af 100755 --- a/apps/login/next.config.mjs +++ b/apps/login/next.config.mjs @@ -12,10 +12,6 @@ const secureHeaders = [ key: "Referrer-Policy", value: "origin-when-cross-origin", }, - { - key: "X-Frame-Options", - value: "SAMEORIGIN", - }, { key: "X-Content-Type-Options", value: "nosniff", @@ -26,7 +22,7 @@ const secureHeaders = [ }, { key: "Content-Security-Policy", - value: `${DEFAULT_CSP} frame-ancestors 'none'`, + value: DEFAULT_CSP, }, { key: "X-Frame-Options", value: "deny" }, ]; diff --git a/apps/login/src/lib/csp.test.ts b/apps/login/src/lib/csp.test.ts new file mode 100644 index 000000000..a0a6f6151 --- /dev/null +++ b/apps/login/src/lib/csp.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "vitest"; +import { buildCSP } from "./csp"; + +describe("buildCSP", () => { + test("returns all base directives with safe defaults", () => { + const csp = buildCSP(); + + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval'"); + expect(csp).toContain("connect-src 'self'"); + expect(csp).toContain("style-src 'self' 'unsafe-inline'"); + expect(csp).toContain("font-src 'self'"); + expect(csp).toContain("img-src 'self'"); + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); + + test("adds serviceUrl to img-src only", () => { + const csp = buildCSP({ serviceUrl: "https://my-instance.zitadel.cloud" }); + + expect(csp).toContain("img-src 'self' https://my-instance.zitadel.cloud"); + expect(csp).toMatch(/font-src 'self'(?:;| |$)/); + }); + + test("keeps frame-ancestors as 'none' when iframeOrigins is empty", () => { + const csp = buildCSP({ iframeOrigins: [] }); + + expect(csp).toContain("frame-ancestors 'none'"); + }); + + test("overrides frame-ancestors when iframeOrigins are provided", () => { + const csp = buildCSP({ + iframeOrigins: ["https://app.example.com", "https://other.example.com"], + }); + + expect(csp).toContain("frame-ancestors https://app.example.com https://other.example.com"); + expect(csp).not.toContain("frame-ancestors 'none'"); + }); + + test("combines serviceUrl and iframeOrigins", () => { + const csp = buildCSP({ + serviceUrl: "https://zitadel.mycompany.com", + iframeOrigins: ["https://portal.mycompany.com"], + }); + + expect(csp).toContain("img-src 'self' https://zitadel.mycompany.com"); + expect(csp).toContain("frame-ancestors https://portal.mycompany.com"); + expect(csp).not.toContain("frame-ancestors 'none'"); + }); + + test("base directives are preserved when options are set", () => { + const csp = buildCSP({ + serviceUrl: "https://example.com", + iframeOrigins: ["https://embed.example.com"], + }); + + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval'"); + expect(csp).toContain("connect-src 'self'"); + expect(csp).toContain("style-src 'self' 'unsafe-inline'"); + expect(csp).toContain("object-src 'none'"); + }); +}); diff --git a/apps/login/src/lib/csp.ts b/apps/login/src/lib/csp.ts new file mode 100644 index 000000000..36af7a142 --- /dev/null +++ b/apps/login/src/lib/csp.ts @@ -0,0 +1,36 @@ + +const BASE_DIRECTIVES: Record = { + "default-src": ["'self'"], + "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + "connect-src": ["'self'"], + "style-src": ["'self'", "'unsafe-inline'"], + "font-src": ["'self'"], + "img-src": ["'self'"], + "frame-ancestors": ["'none'"], + "object-src": ["'none'"], +}; + +export interface CSPOptions { + serviceUrl?: string; + iframeOrigins?: string[]; +} + +export function buildCSP(options: CSPOptions = {}): string { + const directives: Record = { ...BASE_DIRECTIVES }; + + if (options.serviceUrl) { + directives["img-src"] = [...directives["img-src"], options.serviceUrl]; + } + + if (options.iframeOrigins && options.iframeOrigins.length > 0) { + directives["frame-ancestors"] = [...options.iframeOrigins]; + } + + return serializeCSP(directives); +} + +function serializeCSP(directives: Record): string { + return Object.entries(directives) + .map(([key, values]) => [key, ...values].join(" ")) + .join("; "); +} diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 17a3a26d4..cee680a2a 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -19,14 +19,35 @@ import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitad import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_settings_pb"; import { NextRequest, NextResponse } from "next/server"; -import { DEFAULT_CSP } from "../../../constants/csp"; +import { buildCSP } from "../csp"; import escapeHtml from "escape-html"; const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/; const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/; const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/; +function setCSPHeaders( + response: NextResponse, + serviceConfig: ServiceConfig, + securitySettings: SecuritySettings | undefined, +): void { + const iframeOrigins = + securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0 + ? securitySettings.embeddedIframe.allowedOrigins + : undefined; + + response.headers.set( + "Content-Security-Policy", + buildCSP({ serviceUrl: serviceConfig.baseUrl, iframeOrigins }), + ); + + if (!iframeOrigins) { + response.headers.set("X-Frame-Options", "deny"); + } +} + const gotoAccounts = ({ request, requestId, @@ -240,15 +261,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest }); const noSessionResponse = NextResponse.json({ error: "No active session found" }, { status: 400 }); - - if (securitySettings?.embeddedIframe?.enabled) { - securitySettings.embeddedIframe.allowedOrigins; - noSessionResponse.headers.set( - "Content-Security-Policy", - `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, - ); - noSessionResponse.headers.delete("X-Frame-Options"); - } + setCSPHeaders(noSessionResponse, serviceConfig, securitySettings); if (!selectedSession || !selectedSession.id) { return noSessionResponse; @@ -277,15 +290,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr }); const callbackResponse = NextResponse.redirect(callbackUrl); - - if (securitySettings?.embeddedIframe?.enabled) { - securitySettings.embeddedIframe.allowedOrigins; - callbackResponse.headers.set( - "Content-Security-Policy", - `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, - ); - callbackResponse.headers.delete("X-Frame-Options"); - } + setCSPHeaders(callbackResponse, serviceConfig, securitySettings); return callbackResponse; } else { diff --git a/apps/login/src/middleware.ts b/apps/login/src/middleware.ts index 7a9cc7f8f..a3348e583 100644 --- a/apps/login/src/middleware.ts +++ b/apps/login/src/middleware.ts @@ -2,7 +2,7 @@ import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_se import { headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; -import { DEFAULT_CSP } from "../constants/csp"; +import { buildCSP } from "./lib/csp"; import { getServiceConfig } from "./lib/service-url"; export const config = { matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"], @@ -66,12 +66,15 @@ export async function middleware(request: NextRequest) { const securitySettings = await loadSecuritySettings(request); - if (securitySettings?.embeddedIframe?.enabled) { - responseHeaders.set( - "Content-Security-Policy", - `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, - ); - responseHeaders.delete("X-Frame-Options"); + const iframeOrigins = + securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0 + ? securitySettings.embeddedIframe.allowedOrigins + : undefined; + + responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: serviceConfig.baseUrl, iframeOrigins })); + + if (!iframeOrigins) { + responseHeaders.set("X-Frame-Options", "deny"); } request.nextUrl.href = `${serviceConfig.baseUrl}${request.nextUrl.pathname}${request.nextUrl.search}`;