From f905ae750285761b8fab3c6c015ddba633208e48 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Sat, 26 Jul 2025 06:08:20 +0530 Subject: [PATCH] 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 --- __checks__/README.md | 2 + __checks__/csp-login.spec.ts | 22 + .../web/app/(booking-page-wrapper)/layout.tsx | 2 +- apps/web/app/(use-page-wrapper)/layout.tsx | 2 +- apps/web/app/layout.tsx | 4 +- apps/web/app/not-found.tsx | 2 +- apps/web/app/providers.tsx | 5 +- apps/web/components/PageWrapper.tsx | 10 - apps/web/lib/app-providers-app-dir.tsx | 12 +- apps/web/lib/app-providers.tsx | 36 +- apps/web/lib/csp.ts | 94 +--- apps/web/lib/plain/dynamicProvider.tsx | 1 + apps/web/lib/plain/plainChat.tsx | 4 +- apps/web/lib/withNonce.tsx | 44 -- apps/web/middleware.test.ts | 444 +++++++++++++++++- apps/web/middleware.ts | 69 ++- apps/web/modules/auth/login-view.tsx | 4 +- apps/web/pages/_document.tsx | 31 +- vitest.workspace.ts | 9 + 19 files changed, 572 insertions(+), 225 deletions(-) create mode 100644 __checks__/csp-login.spec.ts delete mode 100644 apps/web/lib/withNonce.tsx diff --git a/__checks__/README.md b/__checks__/README.md index 7063c1b63a..10626ac75b 100644 --- a/__checks__/README.md +++ b/__checks__/README.md @@ -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` diff --git a/__checks__/csp-login.spec.ts b/__checks__/csp-login.spec.ts new file mode 100644 index 0000000000..5b1ba2d104 --- /dev/null +++ b/__checks__/csp-login.spec.ts @@ -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" }); + }); +}); diff --git a/apps/web/app/(booking-page-wrapper)/layout.tsx b/apps/web/app/(booking-page-wrapper)/layout.tsx index b2a76da168..ec7d4ee31d 100644 --- a/apps/web/app/(booking-page-wrapper)/layout.tsx +++ b/apps/web/app/(booking-page-wrapper)/layout.tsx @@ -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 ( <> diff --git a/apps/web/app/(use-page-wrapper)/layout.tsx b/apps/web/app/(use-page-wrapper)/layout.tsx index a7be94bd6a..878635c237 100644 --- a/apps/web/app/(use-page-wrapper)/layout.tsx +++ b/apps/web/app/(use-page-wrapper)/layout.tsx @@ -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; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index a54303ed3c..94044fd63b 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 ]} /> - + {children} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index fbd9529d92..fd9c410976 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -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 ( diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 84e1b21e7d..5a7ab55124 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -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 ( - {!isBookingPage ? : null} + {!isBookingPage ? : null} {!isEmbed && !isBookingPage && } {/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */} diff --git a/apps/web/components/PageWrapper.tsx b/apps/web/components/PageWrapper.tsx index b97bd6b86c..957b388152 100644 --- a/apps/web/components/PageWrapper.tsx +++ b/apps/web/components/PageWrapper.tsx @@ -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} />