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}`;