fix: Add CSP back to login page (#22688)

* Middleware only logic to add CSP header as Next.js App Router doesnt allow setting header in server component

* add tests

* Remove nonce from Page Router and fix ts issues

* Add checkly test
This commit is contained in:
Hariom Balhara
2025-07-26 01:38:20 +01:00
committed by GitHub
parent a1c0daa1b5
commit f905ae7502
19 changed files with 572 additions and 225 deletions
+2
View File
@@ -1,4 +1,6 @@
# Checkly Tests
Run as `yarn checkly test`
Optionally, run as `yarn checkly test --record` to be able to get detailed debugging view in Checkly UI
Run the tests for a particylar file as `yarn checkly test {filePattern}`
Deploy the tests as `yarn checkly deploy`
+22
View File
@@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
test.describe("CSP Headers", () => {
test("Login page should have CSP header with nonce", async ({ page }) => {
const targetUrl = process.env.ENVIRONMENT_URL || "https://app.cal.com";
const response = await page.goto(`${targetUrl}/auth/login`);
expect(response?.status()).toBe(200);
const cspHeader = response?.headers()["content-security-policy"];
expect(cspHeader).toBeTruthy();
// Verify nonce is present in CSP header
const nonceMatch = cspHeader?.match(/'nonce-([^']+)'/);
expect(nonceMatch).toBeTruthy();
expect(nonceMatch![1]).toHaveLength(24);
expect(nonceMatch![1]).toMatch(/==$/);
await page.screenshot({ path: "screenshot.jpg" });
});
});
@@ -4,7 +4,7 @@ import PageWrapper from "@components/PageWrapperAppDir";
export default async function BookingPageWrapperLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const nonce = h.get("x-csp-nonce") ?? undefined;
return (
<>
+1 -1
View File
@@ -5,7 +5,7 @@ import PageWrapper from "@components/PageWrapperAppDir";
export default async function PageWrapperLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const nonce = h.get("x-csp-nonce") ?? undefined;
const headScript = process.env.NEXT_PUBLIC_HEAD_SCRIPTS;
const bodyScript = process.env.NEXT_PUBLIC_BODY_SCRIPTS;
+2 -2
View File
@@ -96,7 +96,7 @@ const getInitialProps = async () => {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const h = await headers();
const nonce = h.get("x-csp") ?? "";
const nonce = h.get("x-csp-nonce") ?? "";
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps();
@@ -151,7 +151,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
]}
/>
<Providers isEmbed={isEmbed}>
<Providers isEmbed={isEmbed} nonce={nonce}>
<AppRouterI18nProvider translations={translations} locale={locale} ns={ns}>
{children}
</AppRouterI18nProvider>
+1 -1
View File
@@ -21,7 +21,7 @@ export const generateMetadata = async () => {
const ServerPage = async () => {
const h = await headers();
const nonce = h.get("x-nonce") ?? undefined;
const nonce = h.get("x-csp-nonce") ?? undefined;
const host = h.get("x-forwarded-host") ?? "";
return (
+3 -2
View File
@@ -13,14 +13,15 @@ import PlainChat from "@lib/plain/dynamicProvider";
type ProvidersProps = {
isEmbed: boolean;
children: React.ReactNode;
nonce: string | undefined;
};
export function Providers({ isEmbed, children }: ProvidersProps) {
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
const isBookingPage = useIsBookingPage();
return (
<SessionProvider>
<TrpcProvider>
{!isBookingPage ? <PlainChat /> : null}
{!isBookingPage ? <PlainChat nonce={nonce} /> : null}
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
-10
View File
@@ -44,17 +44,8 @@ function PageWrapper(props: AppProps) {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
// See https://github.com/kentcdodds/nonce-hydration-issues
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
const nonce = typeof window !== "undefined" ? (pageProps.nonce ? "" : undefined) : pageProps.nonce;
const providerProps = {
...props,
pageProps: {
...props.pageProps,
nonce,
},
};
// Use the layout defined at the page level, if available
const getLayout = Component.getLayout ?? ((page) => page);
@@ -79,7 +70,6 @@ function PageWrapper(props: AppProps) {
{...seoConfig.defaultNextSeo}
/>
<Script
nonce={nonce}
id="page-status"
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
// And we don't want it to error here anyways
+5 -7
View File
@@ -16,7 +16,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
@@ -25,12 +24,11 @@ import { getThemeProviderProps } from "./getThemeProviderProps";
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithLocaleProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
}>
>
WithLocaleProps<{
nonce: string | undefined;
themeBasis?: string;
session: Session;
}>
>,
"Component"
> & {
+8 -28
View File
@@ -20,7 +20,6 @@ import { useFlags } from "@calcom/features/flags/hooks";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import type { WithLocaleProps } from "@lib/withLocale";
import type { WithNonceProps } from "@lib/withNonce";
import { useViewerI18n } from "@components/I18nLanguageHandler";
@@ -33,13 +32,11 @@ const I18nextAdapter = appWithTranslation<
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<
NextAppProps<
WithLocaleProps<
WithNonceProps<{
themeBasis?: string;
session: Session;
i18n?: SSRConfig;
}>
>
WithLocaleProps<{
themeBasis?: string;
session: Session;
i18n?: SSRConfig;
}>
>,
"Component"
> & {
@@ -72,12 +69,7 @@ const getEmbedNamespace = (query: ParsedUrlQuery) => {
return typeof window !== "undefined" ? window.getEmbedNamespace() : (query.embed as string) || null;
};
// We dont need to pass nonce to the i18n provider - this was causing x2-x3 re-renders on a hard refresh
type AppPropsWithoutNonce = Omit<AppPropsWithChildren, "pageProps"> & {
pageProps: Omit<AppPropsWithChildren["pageProps"], "nonce">;
};
const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
const CustomI18nextProvider = (props: AppPropsWithChildren) => {
/**
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
**/
@@ -141,7 +133,7 @@ const enum ThemeSupport {
type CalcomThemeProps = PropsWithChildren<
Pick<AppProps, "router"> &
Pick<AppProps["pageProps"], "nonce" | "themeBasis"> &
Pick<AppProps["pageProps"], "themeBasis"> &
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
>;
const CalcomThemeProvider = (props: CalcomThemeProps) => {
@@ -253,7 +245,6 @@ function getThemeProviderProps({
storageKey,
forcedTheme,
themeSupport,
nonce: props.nonce,
enableColorScheme: false,
enableSystem: themeSupport !== ThemeSupport.None,
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
@@ -280,24 +271,13 @@ function OrgBrandProvider({ children }: { children: React.ReactNode }) {
const AppProviders = (props: AppPropsWithChildren) => {
const isBookingPage = useIsBookingPage();
const { pageProps, ...rest } = props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { nonce, ...restPageProps } = pageProps;
const propsWithoutNonce = {
pageProps: {
...restPageProps,
},
...rest,
};
const RemainingProviders = (
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
<CustomI18nextProvider {...propsWithoutNonce}>
<CustomI18nextProvider {...props}>
<TooltipProvider>
<CalcomThemeProvider
themeBasis={props.pageProps.themeBasis}
nonce={props.pageProps.nonce}
isThemeSupported={props.Component.isThemeSupported}
isBookingPage={props.Component.isBookingPage || isBookingPage}
router={props.router}>
+19 -75
View File
@@ -1,6 +1,3 @@
import type { IncomingMessage, OutgoingMessage } from "http";
import type { NextRequest, NextResponse } from "next/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -21,7 +18,7 @@ function getCspPolicy(nonce: string) {
default-src 'self' ${IS_PRODUCTION ? "" : "data:"};
script-src ${
IS_PRODUCTION
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic not supporting strict-dynamic
? // 'self' 'unsafe-inline' https: added for Browsers not supporting strict-dynamic
`'nonce-${nonce}' 'strict-dynamic' 'self' 'unsafe-inline' https:`
: // Note: We could use 'strict-dynamic' with 'nonce-..' instead of unsafe-inline but there are some streaming related scripts that get blocked(because they don't have nonce on them). It causes a really frustrating full page error model by Next.js to show up sometimes
"'unsafe-inline' 'unsafe-eval' https: http:"
@@ -38,77 +35,24 @@ function getCspPolicy(nonce: string) {
`;
}
// Taken from @next-safe/middleware
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
const { pathname } = url;
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
function safeParseString(value: unknown): { success: boolean; data?: string } {
if (typeof value === "string") {
return { success: true, data: value };
}
return { success: false };
}
export function csp(req: IncomingMessage | NextRequest | null, res: OutgoingMessage | NextResponse | null) {
if (!req) {
return { nonce: undefined };
}
const existingNonce = "cache" in req ? req.headers.get("x-nonce") : req.headers["x-nonce"];
if (existingNonce) {
const existingNoneParsed = safeParseString(existingNonce);
return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" };
}
if (!req.url) {
return { nonce: undefined };
}
const CSP_POLICY = process.env.CSP_POLICY;
const cspEnabledForInstance = CSP_POLICY;
export function getCspNonce() {
const nonce = buildNonce(crypto.getRandomValues(new Uint8Array(22)));
const parsedUrl = new URL(req.url, "http://base_url");
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
if (!cspEnabledForPage) {
return {
nonce: undefined,
};
}
// Set x-nonce request header to be used by `getServerSideProps` or similar fns and `Document.getInitialProps` to read the nonce from
// It is generated for all page requests but only used by pages that need CSP
if ("cache" in req) {
req.headers.set("x-nonce", nonce);
} else {
req.headers["x-nonce"] = nonce;
}
if (res) {
const enforced =
"cache" in req ? req.headers.get("x-csp-enforce") === "true" : req.headers["x-csp-enforce"] === "true";
// No need to enable REPORT ONLY mode for CSP unless we start actively working on it. See https://github.com/calcom/cal.com/issues/13844
const name = enforced ? "Content-Security-Policy" : /*"Content-Security-Policy-Report-Only"*/ null;
if (!name) {
return {
nonce: undefined,
};
}
const value = getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim();
if ("body" in res) {
res.headers.set(name, value);
} else {
res.setHeader(name, value);
}
}
return { nonce };
return nonce;
}
export function getCspHeader({ shouldEnforceCsp, nonce }: { shouldEnforceCsp: boolean; nonce: string }) {
const cspHeaderName = shouldEnforceCsp
? "Content-Security-Policy"
: /*"Content-Security-Policy-Report-Only"*/ null;
if (!cspHeaderName) {
return null;
}
const cspHeaderValue = getCspPolicy(nonce)
.replace(/\s{2,}/g, " ")
.trim();
return { name: cspHeaderName, value: cspHeaderValue };
}
+1
View File
@@ -1,4 +1,5 @@
import dynamic from "next/dynamic";
import { Fragment } from "react";
// Preload caused by dynamic import doesn't seem to add nonce and thus preload fails but the functionality still works - https://github.com/vercel/next.js/issues/81260
export default process.env.NEXT_PUBLIC_PLAIN_CHAT_ID ? dynamic(() => import("./plainChat")) : Fragment;
+3 -1
View File
@@ -80,7 +80,7 @@ interface PlainChatConfig {
}
const PlainChat = IS_PLAIN_CHAT_ENABLED
? () => {
? ({ nonce }: { nonce: string | undefined }) => {
const [config, setConfig] = useState<PlainChatConfig | null>(null);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const { data: session } = useSession();
@@ -297,12 +297,14 @@ const PlainChat = IS_PLAIN_CHAT_ENABLED
return (
<>
<Script
nonce={nonce}
id="plain-chat"
src="https://chat.cdn-plain.com/index.js"
strategy="afterInteractive"
onLoad={() => window.plainScriptLoaded?.()}
/>
<Script
nonce={nonce}
id="plain-chat-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: plainChatScript }}
-44
View File
@@ -1,44 +0,0 @@
import type { GetServerSideProps } from "next";
import { csp } from "@lib/csp";
export type WithNonceProps<T extends Record<string, unknown>> = T & {
nonce?: string;
};
/**
* Make any getServerSideProps fn return the nonce so that it can be used by Components in the page to add any script tag.
* Note that if the Components are not adding any script tag then this is not needed. Even in absence of this, Document.getInitialProps would be able to generate nonce itself which it needs to add script tags common to all pages
* There is no harm in wrapping a `getServerSideProps` fn with this even if it doesn't add any script tag.
*/
export default function withNonce<T extends Record<string, unknown>>(
getServerSideProps: GetServerSideProps<T>
): GetServerSideProps<WithNonceProps<T>> {
return async (context) => {
const ssrResponse = await getServerSideProps(context);
if (!("props" in ssrResponse)) {
return ssrResponse;
}
const { nonce } = csp(context.req, context.res);
// Skip nonce property if it's not available instead of setting it to undefined because undefined can't be serialized.
const nonceProps = nonce
? {
nonce,
}
: null;
// Helps in debugging that withNonce was used but a valid nonce couldn't be set
context.res.setHeader("x-csp", nonce ? "ssr" : "false");
return {
...ssrResponse,
props: {
...ssrResponse.props,
...nonceProps,
},
};
};
}
+443 -1
View File
@@ -1,9 +1,154 @@
// Import mocked functions
import { get as edgeConfigGet } from "@vercel/edge-config";
import { NextRequest } from "next/server";
import { describe, it, expect } from "vitest";
import type { Mock } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { checkPostMethod } from "./middleware";
// We'll test the wrapped middleware as it would be used in production
import middleware from "./middleware";
// Mock dependencies at module level
vi.mock("@vercel/edge-config", () => ({
get: vi.fn(),
}));
vi.mock("next-collect/server", () => ({
collectEvents: vi.fn((config: any) => config.middleware),
}));
vi.mock("@calcom/lib/telemetry", () => ({
extendEventData: vi.fn(),
nextCollectBasicSettings: {},
}));
// Mock NextResponse.json since it's not available in test environment
vi.mock("next/server", async () => {
const actual = await vi.importActual<typeof import("next/server")>("next/server");
// Create a NextResponse constructor that returns Response objects
const NextResponse = function (body: any, init?: ResponseInit) {
return new Response(body, init);
};
// Add static methods
NextResponse.json = (body: any, init?: ResponseInit) => {
return new Response(JSON.stringify(body), {
...init,
headers: {
...init?.headers,
"content-type": "application/json",
},
});
};
NextResponse.next = (init?: ResponseInit) => {
const response = new Response(null, {
status: 200,
...init,
headers: {
...init?.headers,
"x-middleware-next": "1",
},
});
// Add cookies property
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
get: vi.fn(),
};
return response;
};
NextResponse.rewrite = (url: URL | string, init?: ResponseInit) => {
const rewriteUrl = typeof url === "string" ? new URL(url) : url;
return new Response(null, {
status: 200,
...init,
headers: {
...init?.headers,
"x-middleware-rewrite": rewriteUrl.toString(),
},
});
};
NextResponse.redirect = (url: URL | string, statusOrInit?: number | ResponseInit) => {
const redirectUrl = typeof url === "string" ? new URL(url) : url;
const status = typeof statusOrInit === "number" ? statusOrInit : statusOrInit?.status || 307;
const init = typeof statusOrInit === "object" ? statusOrInit : {};
const response = new Response(null, {
...init,
status,
headers: {
...init.headers,
location: redirectUrl.toString(),
},
});
// Add cookies property
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
get: vi.fn(),
};
return response;
};
return {
...actual,
NextResponse,
};
});
const createTestRequest = (overrides?: {
url?: string;
method?: string;
headers?: Record<string, string>;
cookies?: Record<string, string>;
}) => {
const url = overrides?.url || `${WEBAPP_URL}/test-path`;
const method = overrides?.method || "GET";
const headers = new Headers(overrides?.headers || {});
const req = new NextRequest(new Request(url, { method, headers }));
// Add cookies if provided
if (overrides?.cookies) {
Object.entries(overrides.cookies).forEach(([name, value]) => {
req.cookies.set(name, value);
});
}
return req;
};
const createEdgeConfigMock = (config: Record<string, any>) => {
return (key: string) => {
if (key in config) return Promise.resolve(config[key]);
return Promise.resolve(undefined);
};
};
// Helper to safely get header value
const getHeader = (res: Response, name: string) => {
return res.headers.get(name);
};
// Helper to check response status
const expectStatus = (res: Response, status: number) => {
expect(res.status).toBe(status);
};
// Wrapper for middleware calls to handle type casting
const callMiddleware = async (req: NextRequest): Promise<Response> => {
return (await (middleware as any)(req)) as Response;
};
describe("Middleware - POST requests restriction", () => {
const createRequest = (path: string, method: string) => {
@@ -41,3 +186,300 @@ describe("Middleware - POST requests restriction", () => {
expect(res).toBeNull();
});
});
describe("Middleware Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
// Set test environment
vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", WEBAPP_URL);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Static File Handling", () => {
it("should skip processing for static files", async () => {
const staticPaths = ["/images/logo.png", "/_next/static/main.js", "/favicon.ico"];
for (const path of staticPaths) {
const req = createTestRequest({ url: `${WEBAPP_URL}${path}` });
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
expect(getHeader(res, "x-middleware-next")).toBe("1");
}
});
});
describe("POST Method Protection", () => {
it("should allow POST to /api/auth/signup", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
it("should block POST to regular routes", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 405);
expect(getHeader(res, "Allow")).toBe("GET");
});
});
describe("Maintenance Mode", () => {
it("should redirect to maintenance when enabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
it("should not affect API routes in maintenance mode", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/api/bookings`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
});
describe("Signup Control", () => {
it("should block signup when disabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isSignupDisabled: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 503);
const body = await res.text();
expect(body).toContain("Signup is disabled");
});
it("should allow signup when enabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isSignupDisabled: false,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
});
describe("Cookie Management", () => {
it("should handle return-to cookie redirect", async () => {
const returnUrl = "/dashboard";
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/installed`,
cookies: { "return-to": returnUrl },
});
const res = await callMiddleware(req);
expectStatus(res, 307);
expect(getHeader(res, "location")).toContain(returnUrl);
});
it("should not redirect without return-to cookie", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/installed`,
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
it("should delete session cookie on logout", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/auth/logout`,
});
const res = await callMiddleware(req);
const setCookie = getHeader(res, "set-cookie");
if (setCookie) {
expect(setCookie).toContain("next-auth.session-token");
}
});
});
describe("Embed Routes", () => {
it("should add embed headers", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test/embed`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-isEmbed")).toBe("true");
});
it("should add COEP header when requested", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test/embed?flag.coep=true`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "Cross-Origin-Embedder-Policy")).toBe("require-corp");
});
it("should add color scheme header", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test/embed?ui.color-scheme=dark`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-embedColorScheme")).toBe("dark");
});
});
describe("CSP Headers", () => {
beforeEach(() => {
vi.stubEnv("CSP_POLICY", "strict");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("should add CSP headers to login pages", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/auth/login`,
});
const res = await callMiddleware(req);
const cspHeader = getHeader(res, "content-security-policy");
expect(cspHeader).toBeTruthy();
expect(cspHeader).toContain("default-src");
expect(cspHeader).toContain("script-src");
});
it("should not add CSP headers to non-login pages", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
});
const res = await callMiddleware(req);
const cspHeader = getHeader(res, "content-security-policy");
expect(cspHeader).toBeNull();
});
it("should add x-csp-status when CSP_POLICY not set", async () => {
vi.unstubAllEnvs();
const req = createTestRequest({
url: `${WEBAPP_URL}/auth/login`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-csp-status")).toBe("not-opted-in");
});
});
describe("Routing Forms", () => {
it("should rewrite legacy routing_forms URLs", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/routing_forms/form-id`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
});
describe("Multiple Features", () => {
it("should handle POST protection before maintenance mode", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
// POST protection should trigger first
expectStatus(res, 405);
});
it("should handle embed route with routing forms rewrite", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/apps/routing_forms/form/embed?ui.color-scheme=light`,
});
const res = await callMiddleware(req);
expect(getHeader(res, "x-isEmbed")).toBe("true");
expect(getHeader(res, "x-embedColorScheme")).toBe("light");
});
});
describe("Error Handling", () => {
it("should handle Edge Config errors gracefully", async () => {
(edgeConfigGet as Mock).mockImplementation(() => {
throw new Error("Config error");
});
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
expectStatus(res, 200);
});
it("should handle malformed URLs", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}//double-slash`,
});
const res = await callMiddleware(req);
expect(res).toBeDefined();
});
});
});
+47 -22
View File
@@ -5,7 +5,7 @@ import { NextResponse } from "next/server";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
import { csp } from "./lib/csp";
import { getCspHeader, getCspNonce } from "@lib/csp";
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
try {
@@ -38,6 +38,17 @@ export function checkStaticFiles(pathname: string) {
}
}
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
const { pathname } = url;
return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname);
};
const shouldEnforceCsp = (url: URL) => {
return url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login");
};
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const postCheckResult = checkPostMethod(req);
if (postCheckResult) return postCheckResult;
@@ -46,7 +57,8 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
if (isStaticFile) return isStaticFile;
const url = req.nextUrl;
const requestHeaders = new Headers(req.headers);
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
if (!url.pathname.startsWith("/api")) {
//
@@ -59,14 +71,14 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
req.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(req.nextUrl);
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
}
}
const routingFormRewriteResponse = routingForms.handleRewrite(url);
if (routingFormRewriteResponse) {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req });
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
}
if (url.pathname.startsWith("/api/auth/signup")) {
@@ -78,16 +90,13 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
}
}
if (url.pathname.startsWith("/auth/login") || url.pathname.startsWith("/login")) {
// Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages.
requestHeaders.set("x-csp-enforce", "true");
}
if (url.pathname.startsWith("/apps/installed")) {
const returnTo = req.cookies.get("return-to");
const returnTo = reqWithEnrichedHeaders.cookies.get("return-to");
if (returnTo?.value) {
const response = NextResponse.redirect(new URL(returnTo.value, req.url), { headers: requestHeaders });
const response = NextResponse.redirect(new URL(returnTo.value, reqWithEnrichedHeaders.url), {
headers: requestHeaders,
});
response.cookies.delete("return-to");
return response;
}
@@ -103,7 +112,7 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
res.cookies.delete("next-auth.session-token");
}
return responseWithHeaders({ url, res, req });
return responseWithHeaders({ url, res, req: reqWithEnrichedHeaders });
};
const routingForms = {
@@ -138,18 +147,29 @@ const embeds = {
const contentSecurityPolicy = {
addResponseHeaders: ({ res, req }: { res: NextResponse; req: NextRequest }) => {
const { nonce } = csp(req, res ?? null);
if (!process.env.CSP_POLICY) {
res.headers.set("x-csp", "not-opted-in");
} else if (!res.headers.get("x-csp")) {
// If x-csp not set by gSSP, then it's initialPropsOnly
res.headers.set("x-csp", "initialPropsOnly");
} else {
res.headers.set("x-csp", nonce ?? "");
const nonce = req.headers.get("x-csp-nonce");
if (!nonce) {
res.headers.set("x-csp-status", "not-opted-in");
return res;
}
const cspHeader = getCspHeader({ shouldEnforceCsp: shouldEnforceCsp(req.nextUrl), nonce });
if (cspHeader) {
res.headers.set(cspHeader.name, cspHeader.value);
}
return res;
},
addRequestHeaders: ({ req }: { req: NextRequest }) => {
if (!process.env.CSP_POLICY) {
return req;
}
const isCspApplicable = isPagePathRequest(req.nextUrl);
if (!isCspApplicable) {
return req;
}
const nonce = getCspNonce();
req.headers.set("x-csp-nonce", nonce);
return req;
},
};
function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; req: NextRequest }) {
@@ -158,6 +178,11 @@ function responseWithHeaders({ url, res, req }: { url: URL; res: NextResponse; r
return resWithEmbeds;
}
function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
const reqWithCSP = contentSecurityPolicy.addRequestHeaders({ req });
return reqWithCSP;
}
export const config = {
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
// https://github.com/vercel/next.js/discussions/42458
+1 -3
View File
@@ -25,7 +25,6 @@ import { Button } from "@calcom/ui/components/button";
import { EmailField, PasswordField } from "@calcom/ui/components/form";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import type { WithNonceProps } from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import BackupCode from "@components/auth/BackupCode";
@@ -53,8 +52,7 @@ export default function Login({
samlTenantID,
samlProductID,
totpEmail,
}: // eslint-disable-next-line @typescript-eslint/ban-types
PageProps & WithNonceProps<{}>) {
}: PageProps) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
+4 -27
View File
@@ -1,35 +1,16 @@
import type { IncomingMessage } from "http";
import { dir } from "i18next";
import type { NextPageContext } from "next";
import type { DocumentContext, DocumentProps } from "next/document";
import Document, { Head, Html, Main, NextScript } from "next/document";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { csp } from "@lib/csp";
import { applyTheme } from "./_applyThemeForDocument";
type Props = Record<string, unknown> & DocumentProps & { newLocale: string };
function setHeader(ctx: NextPageContext, name: string, value: string) {
try {
ctx.res?.setHeader(name, value);
} catch (e) {
// Getting "Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client" when revalidate calendar cache
console.log(`Error setting header ${name}=${value} for ${ctx.asPath || "unknown asPath"}`, e);
}
}
class MyDocument extends Document<Props> {
static async getInitialProps(ctx: DocumentContext) {
const { nonce } = csp(ctx.req || null, ctx.res || null);
if (!process.env.CSP_POLICY) {
setHeader(ctx, "x-csp", "not-opted-in");
} else if (!ctx.res?.getHeader("x-csp")) {
// If x-csp not set by gSSP, then it's initialPropsOnly
setHeader(ctx, "x-csp", "initialPropsOnly");
}
const getLocaleModule = ctx.req ? await import("@calcom/features/auth/lib/getLocale") : null;
const newLocale =
@@ -48,7 +29,7 @@ class MyDocument extends Document<Props> {
!isEmbedSnippetGeneratorPath;
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, embedColorScheme, nonce, ...initialProps, newLocale };
return { isEmbed, embedColorScheme, ...initialProps, newLocale };
}
render() {
@@ -56,17 +37,13 @@ class MyDocument extends Document<Props> {
const newLocale = this.props.newLocale || "en";
const newDir = dir(newLocale);
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
return (
<Html
lang={newLocale}
dir={newDir}
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
<Head nonce={nonce}>
<Head>
<script
nonce={nonce}
id="newLocale"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
@@ -108,7 +85,7 @@ class MyDocument extends Document<Props> {
: {}
}>
<Main />
<NextScript nonce={nonce} />
<NextScript />
</body>
</Html>
);
+9
View File
@@ -82,6 +82,15 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/lib",
setupFiles: ["setupVitest.ts"],
},
resolve: {
alias: {
"@lib": new URL("./apps/web/lib", import.meta.url).pathname,
"@server": new URL("./apps/web/server", import.meta.url).pathname,
"@components": new URL("./apps/web/components", import.meta.url).pathname,
"@pages": new URL("./apps/web/pages", import.meta.url).pathname,
"~": new URL("./apps/web/modules", import.meta.url).pathname,
},
},
},
{
test: {