Merge pull request #444 from zitadel/iframe

fix(security-settings): override csp for allowed iframe
This commit is contained in:
Max Peintner
2025-04-30 10:17:05 +02:00
committed by GitHub
10 changed files with 177 additions and 70 deletions

View File

@@ -0,0 +1,2 @@
export const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;";

View File

@@ -6,6 +6,13 @@
"data": {} "data": {}
} }
}, },
{
"service": "zitadel.settings.v2.SettingsService",
"method": "GetSecuritySettings",
"out": {
"data": {}
}
},
{ {
"service": "zitadel.settings.v2.SettingsService", "service": "zitadel.settings.v2.SettingsService",
"method": "GetLegalAndSupportSettings", "method": "GetLegalAndSupportSettings",

View File

@@ -1,4 +1,5 @@
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import { DEFAULT_CSP } from "./constants/csp.js";
const withNextIntl = createNextIntlPlugin(); const withNextIntl = createNextIntlPlugin();
@@ -29,9 +30,9 @@ const secureHeaders = [
// script-src va.vercel-scripts.com for analytics/vercel scripts // script-src va.vercel-scripts.com for analytics/vercel scripts
{ {
key: "Content-Security-Policy", key: "Content-Security-Policy",
value: value: `${DEFAULT_CSP} frame-ancestors 'none'`,
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;",
}, },
{ key: "X-Frame-Options", value: "deny" },
]; ];
const imageRemotePatterns = [ const imageRemotePatterns = [

View File

@@ -33,8 +33,8 @@ export default async function Page(props: {
const { serviceUrl } = getServiceUrlFromHeaders(_headers); const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const sessionWithData = sessionId const sessionWithData = sessionId
? await loadSessionById(serviceUrl, sessionId, organization) ? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(serviceUrl, loginName, organization); : await loadSessionByLoginname(loginName, organization);
async function getAuthMethodsAndUser( async function getAuthMethodsAndUser(
serviceUrl: string, serviceUrl: string,
@@ -67,7 +67,6 @@ export default async function Page(props: {
} }
async function loadSessionByLoginname( async function loadSessionByLoginname(
host: string,
loginName?: string, loginName?: string,
organization?: string, organization?: string,
) { ) {
@@ -82,11 +81,7 @@ export default async function Page(props: {
}); });
} }
async function loadSessionById( async function loadSessionById(sessionId: string, organization?: string) {
host: string,
sessionId: string,
organization?: string,
) {
const recent = await getSessionCookieById({ sessionId, organization }); const recent = await getSessionCookieById({ sessionId, organization });
return getSession({ return getSession({
serviceUrl, serviceUrl,

View File

@@ -12,6 +12,7 @@ import {
getAuthRequest, getAuthRequest,
getOrgsByDomain, getOrgsByDomain,
getSAMLRequest, getSAMLRequest,
getSecuritySettings,
listSessions, listSessions,
startIdentityProviderFlow, startIdentityProviderFlow,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
@@ -25,6 +26,7 @@ import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const revalidate = false; export const revalidate = false;
@@ -293,17 +295,32 @@ export async function GET(request: NextRequest) {
* This means that the user should not be prompted to enter their password again. * This means that the user should not be prompted to enter their password again.
* Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction * Instead, the server attempts to silently authenticate the user using an existing session or other authentication mechanisms that do not require user interaction
**/ **/
const securitySettings = await getSecuritySettings({
serviceUrl,
});
const selectedSession = await findValidSession({ const selectedSession = await findValidSession({
serviceUrl, serviceUrl,
sessions, sessions,
authRequest, authRequest,
}); });
if (!selectedSession || !selectedSession.id) { const noSessionResponse = NextResponse.json(
return NextResponse.json( { error: "No active session found" },
{ error: "No active session found" }, { status: 400 },
{ 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");
}
if (!selectedSession || !selectedSession.id) {
return noSessionResponse;
} }
const cookie = sessionCookies.find( const cookie = sessionCookies.find(
@@ -311,10 +328,7 @@ export async function GET(request: NextRequest) {
); );
if (!cookie || !cookie.id || !cookie.token) { if (!cookie || !cookie.id || !cookie.token) {
return NextResponse.json( return noSessionResponse;
{ error: "No active session found" },
{ status: 400 },
);
} }
const session = { const session = {
@@ -332,7 +346,19 @@ export async function GET(request: NextRequest) {
}, },
}), }),
}); });
return NextResponse.redirect(callbackUrl);
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");
}
return callbackResponse;
} else { } else {
// check for loginHint, userId hint and valid sessions // check for loginHint, userId hint and valid sessions
let selectedSession = await findValidSession({ let selectedSession = await findValidSession({

View File

@@ -20,7 +20,10 @@ export type Cookie = {
type SessionCookie<T> = Cookie & T; type SessionCookie<T> = Cookie & T;
async function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[]) { async function setSessionHttpOnlyCookie<T>(
sessions: SessionCookie<T>[],
sameSite: boolean | "lax" | "strict" | "none" = true,
) {
const cookiesList = await cookies(); const cookiesList = await cookies();
return cookiesList.set({ return cookiesList.set({
@@ -28,6 +31,7 @@ async function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[]) {
value: JSON.stringify(sessions), value: JSON.stringify(sessions),
httpOnly: true, httpOnly: true,
path: "/", path: "/",
sameSite,
}); });
} }
@@ -42,10 +46,15 @@ export async function setLanguageCookie(language: string) {
}); });
} }
export async function addSessionToCookie<T>( export async function addSessionToCookie<T>({
session: SessionCookie<T>, session,
cleanup: boolean = false, cleanup,
): Promise<any> { sameSite,
}: {
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
}): Promise<any> {
const cookiesList = await cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
@@ -79,17 +88,23 @@ export async function addSessionToCookie<T>(
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true, : true,
); );
return setSessionHttpOnlyCookie(filteredSessions); return setSessionHttpOnlyCookie(filteredSessions, sameSite);
} else { } else {
return setSessionHttpOnlyCookie(currentSessions); return setSessionHttpOnlyCookie(currentSessions, sameSite);
} }
} }
export async function updateSessionCookie<T>( export async function updateSessionCookie<T>({
id: string, id,
session: SessionCookie<T>, session,
cleanup: boolean = false, cleanup,
): Promise<any> { sameSite,
}: {
id: string;
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
}): Promise<any> {
const cookiesList = await cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
@@ -108,19 +123,24 @@ export async function updateSessionCookie<T>(
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true, : true,
); );
return setSessionHttpOnlyCookie(filteredSessions); return setSessionHttpOnlyCookie(filteredSessions, sameSite);
} else { } else {
return setSessionHttpOnlyCookie(sessions); return setSessionHttpOnlyCookie(sessions, sameSite);
} }
} else { } else {
throw "updateSessionCookie<T>: session id now found"; throw "updateSessionCookie<T>: session id now found";
} }
} }
export async function removeSessionFromCookie<T>( export async function removeSessionFromCookie<T>({
session: SessionCookie<T>, session,
cleanup: boolean = false, cleanup,
): Promise<any> { sameSite,
}: {
session: SessionCookie<T>;
cleanup?: boolean;
sameSite?: boolean | "lax" | "strict" | "none" | undefined;
}): Promise<any> {
const cookiesList = await cookies(); const cookiesList = await cookies();
const stringifiedCookie = cookiesList.get("sessions"); const stringifiedCookie = cookiesList.get("sessions");
@@ -136,9 +156,9 @@ export async function removeSessionFromCookie<T>(
? timestampDate(timestampFromMs(Number(session.expirationTs))) > now ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now
: true, : true,
); );
return setSessionHttpOnlyCookie(filteredSessions); return setSessionHttpOnlyCookie(filteredSessions, sameSite);
} else { } else {
return setSessionHttpOnlyCookie(reducedSessions); return setSessionHttpOnlyCookie(reducedSessions, sameSite);
} }
} }

View File

@@ -4,6 +4,7 @@ import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies";
import { import {
createSessionForUserIdAndIdpIntent, createSessionForUserIdAndIdpIntent,
createSessionFromChecks, createSessionFromChecks,
getSecuritySettings,
getSession, getSession,
setSession, setSession,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
@@ -65,7 +66,7 @@ export async function createSessionAndUpdateCookie(command: {
serviceUrl, serviceUrl,
sessionId: createdSession.sessionId, sessionId: createdSession.sessionId,
sessionToken: createdSession.sessionToken, sessionToken: createdSession.sessionToken,
}).then((response) => { }).then(async (response) => {
if (response?.session && response.session?.factors?.user?.loginName) { if (response?.session && response.session?.factors?.user?.loginName) {
const sessionCookie: CustomCookieData = { const sessionCookie: CustomCookieData = {
id: createdSession.sessionId, id: createdSession.sessionId,
@@ -91,9 +92,14 @@ export async function createSessionAndUpdateCookie(command: {
response.session.factors.user.organizationId; response.session.factors.user.organizationId;
} }
return addSessionToCookie(sessionCookie).then(() => { const securitySettings = await getSecuritySettings({ serviceUrl });
return response.session as Session; const sameSite = securitySettings?.embeddedIframe?.enabled
}); ? "none"
: true;
await addSessionToCookie({ session: sessionCookie, sameSite });
return response.session as Session;
} else { } else {
throw "could not get session or session does not have loginName"; throw "could not get session or session does not have loginName";
} }
@@ -167,7 +173,10 @@ export async function createSessionForIdpAndUpdateCookie(
sessionCookie.organization = session.factors.user.organizationId; sessionCookie.organization = session.factors.user.organizationId;
} }
return addSessionToCookie(sessionCookie).then(() => { const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true;
return addSessionToCookie({ session: sessionCookie, sameSite }).then(() => {
return session as Session; return session as Session;
}); });
} }
@@ -217,32 +226,44 @@ export async function setSessionAndUpdateCookie(
serviceUrl, serviceUrl,
sessionId: sessionCookie.id, sessionId: sessionCookie.id,
sessionToken: sessionCookie.token, sessionToken: sessionCookie.token,
}).then((response) => { }).then(async (response) => {
if (response?.session && response.session.factors?.user?.loginName) { if (
const { session } = response; !response?.session ||
const newCookie: CustomCookieData = { !response.session.factors?.user?.loginName
id: sessionCookie.id, ) {
token: updatedSession.sessionToken,
creationTs: sessionCookie.creationTs,
expirationTs: sessionCookie.expirationTs,
// just overwrite the changeDate with the new one
changeTs: updatedSession.details?.changeDate
? `${timestampMs(updatedSession.details.changeDate)}`
: "",
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",
};
if (sessionCookie.requestId) {
newCookie.requestId = sessionCookie.requestId;
}
return updateSessionCookie(sessionCookie.id, newCookie).then(() => {
return { challenges: updatedSession.challenges, ...session };
});
} else {
throw "could not get session or session does not have loginName"; throw "could not get session or session does not have loginName";
} }
const { session } = response;
const newCookie: CustomCookieData = {
id: sessionCookie.id,
token: updatedSession.sessionToken,
creationTs: sessionCookie.creationTs,
expirationTs: sessionCookie.expirationTs,
// just overwrite the changeDate with the new one
changeTs: updatedSession.details?.changeDate
? `${timestampMs(updatedSession.details.changeDate)}`
: "",
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",
};
if (sessionCookie.requestId) {
newCookie.requestId = sessionCookie.requestId;
}
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled
? "none"
: true;
return updateSessionCookie({
id: sessionCookie.id,
session: newCookie,
sameSite,
}).then(() => {
return { challenges: updatedSession.challenges, ...session };
});
}); });
} else { } else {
throw "Session not be set"; throw "Session not be set";

View File

@@ -4,6 +4,7 @@ import { setSessionAndUpdateCookie } from "@/lib/server/cookie";
import { import {
deleteSession, deleteSession,
getLoginSettings, getLoginSettings,
getSecuritySettings,
humanMFAInitSkipped, humanMFAInitSkipped,
listAuthenticationMethodTypes, listAuthenticationMethodTypes,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
@@ -209,8 +210,11 @@ export async function clearSession(options: ClearSessionOptions) {
sessionToken: session.token, sessionToken: session.token,
}); });
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true;
if (deletedSession) { if (deletedSession) {
return removeSessionFromCookie(session); return removeSessionFromCookie({ session, sameSite });
} }
} }
@@ -230,9 +234,12 @@ export async function cleanupSession({ sessionId }: CleanupSessionCommand) {
sessionToken: sessionCookie.token, sessionToken: sessionCookie.token,
}); });
const securitySettings = await getSecuritySettings({ serviceUrl });
const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true;
if (!deleteResponse) { if (!deleteResponse) {
throw new Error("Could not delete session"); throw new Error("Could not delete session");
} }
return removeSessionFromCookie(sessionCookie); return removeSessionFromCookie({ session: sessionCookie, sameSite });
} }

View File

@@ -92,6 +92,21 @@ export async function getLoginSettings({
return useCache ? cacheWrapper(callback) : callback; return useCache ? cacheWrapper(callback) : callback;
} }
export async function getSecuritySettings({
serviceUrl,
}: {
serviceUrl: string;
}) {
const settingsService: Client<typeof SettingsService> =
await createServiceForHost(SettingsService, serviceUrl);
const callback = settingsService
.getSecuritySettings({})
.then((resp) => (resp.settings ? resp.settings : undefined));
return useCache ? cacheWrapper(callback) : callback;
}
export async function getLockoutSettings({ export async function getLockoutSettings({
serviceUrl, serviceUrl,
orgId, orgId,

View File

@@ -1,6 +1,8 @@
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 { getServiceUrlFromHeaders } from "./lib/service"; import { getServiceUrlFromHeaders } from "./lib/service";
import { getSecuritySettings } from "./lib/zitadel";
export const config = { export const config = {
matcher: [ matcher: [
@@ -22,6 +24,8 @@ export async function middleware(request: NextRequest) {
const { serviceUrl } = getServiceUrlFromHeaders(_headers); const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const securitySettings = await getSecuritySettings({ serviceUrl });
const instanceHost = `${serviceUrl}` const instanceHost = `${serviceUrl}`
.replace("https://", "") .replace("https://", "")
.replace("http://", ""); .replace("http://", "");
@@ -39,6 +43,15 @@ export async function middleware(request: NextRequest) {
responseHeaders.set("Access-Control-Allow-Origin", "*"); responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Access-Control-Allow-Headers", "*"); responseHeaders.set("Access-Control-Allow-Headers", "*");
if (securitySettings?.embeddedIframe?.enabled) {
securitySettings.embeddedIframe.allowedOrigins;
responseHeaders.set(
"Content-Security-Policy",
`${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`,
);
responseHeaders.delete("X-Frame-Options");
}
request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`;
return NextResponse.rewrite(request.nextUrl, { return NextResponse.rewrite(request.nextUrl, {
request: { request: {