fix(login): CSP img-src resolved at build time instead of runtime (#11603)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# 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
This commit is contained in:
Liam Neville
2026-02-20 12:20:02 +00:00
committed by GitHub
parent 1a2c2d3183
commit df8824e16c
6 changed files with 135 additions and 36 deletions
+1 -5
View File
@@ -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'";
+1 -5
View File
@@ -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" },
];
+63
View File
@@ -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'");
});
});
+36
View File
@@ -0,0 +1,36 @@
const BASE_DIRECTIVES: Record<string, string[]> = {
"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<string, string[]> = { ...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, string[]>): string {
return Object.entries(directives)
.map(([key, values]) => [key, ...values].join(" "))
.join("; ");
}
+24 -19
View File
@@ -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 {
+10 -7
View File
@@ -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}`;