mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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 =
|
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'";
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ const secureHeaders = [
|
|||||||
key: "Referrer-Policy",
|
key: "Referrer-Policy",
|
||||||
value: "origin-when-cross-origin",
|
value: "origin-when-cross-origin",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "X-Frame-Options",
|
|
||||||
value: "SAMEORIGIN",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "X-Content-Type-Options",
|
key: "X-Content-Type-Options",
|
||||||
value: "nosniff",
|
value: "nosniff",
|
||||||
@@ -26,7 +22,7 @@ const secureHeaders = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "Content-Security-Policy",
|
key: "Content-Security-Policy",
|
||||||
value: `${DEFAULT_CSP} frame-ancestors 'none'`,
|
value: DEFAULT_CSP,
|
||||||
},
|
},
|
||||||
{ key: "X-Frame-Options", value: "deny" },
|
{ key: "X-Frame-Options", value: "deny" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("; ");
|
||||||
|
}
|
||||||
@@ -19,14 +19,35 @@ import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitad
|
|||||||
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
|
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
|
||||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||||
import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { DEFAULT_CSP } from "../../../constants/csp";
|
import { buildCSP } from "../csp";
|
||||||
import escapeHtml from "escape-html";
|
import escapeHtml from "escape-html";
|
||||||
|
|
||||||
const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
|
const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
|
||||||
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/;
|
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/;
|
||||||
const IDP_SCOPE_REGEX = /urn:zitadel:iam:org:idp:id:(.+)/;
|
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 = ({
|
const gotoAccounts = ({
|
||||||
request,
|
request,
|
||||||
requestId,
|
requestId,
|
||||||
@@ -240,15 +261,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
|
|||||||
const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest });
|
const selectedSession = await findValidSession({ serviceConfig, sessions, authRequest });
|
||||||
|
|
||||||
const noSessionResponse = NextResponse.json({ error: "No active session found" }, { status: 400 });
|
const noSessionResponse = NextResponse.json({ error: "No active session found" }, { status: 400 });
|
||||||
|
setCSPHeaders(noSessionResponse, serviceConfig, securitySettings);
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedSession || !selectedSession.id) {
|
if (!selectedSession || !selectedSession.id) {
|
||||||
return noSessionResponse;
|
return noSessionResponse;
|
||||||
@@ -277,15 +290,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
|
|||||||
});
|
});
|
||||||
|
|
||||||
const callbackResponse = NextResponse.redirect(callbackUrl);
|
const callbackResponse = NextResponse.redirect(callbackUrl);
|
||||||
|
setCSPHeaders(callbackResponse, serviceConfig, securitySettings);
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
return callbackResponse;
|
return callbackResponse;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_se
|
|||||||
|
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { DEFAULT_CSP } from "../constants/csp";
|
import { buildCSP } from "./lib/csp";
|
||||||
import { getServiceConfig } from "./lib/service-url";
|
import { getServiceConfig } from "./lib/service-url";
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*", "/idps/callback/:path*", "/saml/:path*", "/:path*"],
|
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);
|
const securitySettings = await loadSecuritySettings(request);
|
||||||
|
|
||||||
if (securitySettings?.embeddedIframe?.enabled) {
|
const iframeOrigins =
|
||||||
responseHeaders.set(
|
securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0
|
||||||
"Content-Security-Policy",
|
? securitySettings.embeddedIframe.allowedOrigins
|
||||||
`${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`,
|
: undefined;
|
||||||
);
|
|
||||||
responseHeaders.delete("X-Frame-Options");
|
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}`;
|
request.nextUrl.href = `${serviceConfig.baseUrl}${request.nextUrl.pathname}${request.nextUrl.search}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user