diff --git a/.env.example b/.env.example index ebe8537385..cffb8c02d1 100644 --- a/.env.example +++ b/.env.example @@ -157,3 +157,6 @@ NEXT_PUBLIC_COMPANY_NAME="Cal.com, Inc." # Set this to true in to disable new signups # NEXT_PUBLIC_DISABLE_SIGNUP=true NEXT_PUBLIC_DISABLE_SIGNUP= + +# Content Security Policy +CSP_POLICY= diff --git a/README.md b/README.md index 69454c9fae..4b8ffab14e 100644 --- a/README.md +++ b/README.md @@ -350,7 +350,9 @@ Don't code but still want to contribute? Join our [slack](https://cal.com/slack) ![ar translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ar&style=flat&logo=crowdin&query=%24.progress.0.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![bg translation](https://img.shields.io/badge/dynamic/json?color=blue&label=bg&style=flat&logo=crowdin&query=%24.progress.1.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![cs translation](https://img.shields.io/badge/dynamic/json?color=blue&label=cs&style=flat&logo=crowdin&query=%24.progress.2.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![de translation](https://img.shields.io/badge/dynamic/json?color=blue&label=de&style=flat&logo=crowdin&query=%24.progress.3.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![el translation](https://img.shields.io/badge/dynamic/json?color=blue&label=el&style=flat&logo=crowdin&query=%24.progress.4.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![en translation](https://img.shields.io/badge/dynamic/json?color=blue&label=en&style=flat&logo=crowdin&query=%24.progress.5.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![es translation](https://img.shields.io/badge/dynamic/json?color=blue&label=es&style=flat&logo=crowdin&query=%24.progress.6.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![es-419 translation](https://img.shields.io/badge/dynamic/json?color=blue&label=es-419&style=flat&logo=crowdin&query=%24.progress.7.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![fr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=fr&style=flat&logo=crowdin&query=%24.progress.8.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![he translation](https://img.shields.io/badge/dynamic/json?color=blue&label=he&style=flat&logo=crowdin&query=%24.progress.9.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![hu translation](https://img.shields.io/badge/dynamic/json?color=blue&label=hu&style=flat&logo=crowdin&query=%24.progress.10.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![it translation](https://img.shields.io/badge/dynamic/json?color=blue&label=it&style=flat&logo=crowdin&query=%24.progress.11.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ja translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ja&style=flat&logo=crowdin&query=%24.progress.12.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ko translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ko&style=flat&logo=crowdin&query=%24.progress.13.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![nl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=nl&style=flat&logo=crowdin&query=%24.progress.14.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![no translation](https://img.shields.io/badge/dynamic/json?color=blue&label=no&style=flat&logo=crowdin&query=%24.progress.15.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pl&style=flat&logo=crowdin&query=%24.progress.16.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![pt translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pt&style=flat&logo=crowdin&query=%24.progress.17.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![pt-BR translation](https://img.shields.io/badge/dynamic/json?color=blue&label=pt-BR&style=flat&logo=crowdin&query=%24.progress.18.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ro translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ro&style=flat&logo=crowdin&query=%24.progress.19.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=ru&style=flat&logo=crowdin&query=%24.progress.20.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![sr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=sr&style=flat&logo=crowdin&query=%24.progress.21.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![sv translation](https://img.shields.io/badge/dynamic/json?color=blue&label=sv&style=flat&logo=crowdin&query=%24.progress.22.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![tr translation](https://img.shields.io/badge/dynamic/json?color=blue&label=tr&style=flat&logo=crowdin&query=%24.progress.23.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![uk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=uk&style=flat&logo=crowdin&query=%24.progress.24.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![vi translation](https://img.shields.io/badge/dynamic/json?color=blue&label=vi&style=flat&logo=crowdin&query=%24.progress.25.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=zh-CN&style=flat&logo=crowdin&query=%24.progress.26.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) ![zh-TW translation](https://img.shields.io/badge/dynamic/json?color=blue&label=zh-TW&style=flat&logo=crowdin&query=%24.progress.27.data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-200011276-1.json) +## Enabling Content Security Policy +- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported. ## Integrations diff --git a/apps/web/lib/app-providers.tsx b/apps/web/lib/app-providers.tsx index 276a2721de..78b43d3d59 100644 --- a/apps/web/lib/app-providers.tsx +++ b/apps/web/lib/app-providers.tsx @@ -13,18 +13,20 @@ import { trpc } from "@calcom/trpc/react"; import { MetaProvider } from "@calcom/ui"; import usePublicPage from "@lib/hooks/usePublicPage"; +import { WithNonceProps } from "@lib/withNonce"; const I18nextAdapter = appWithTranslation & { children: React.ReactNode }>( ({ children }) => <>{children} ); // Workaround for https://github.com/vercel/next.js/issues/8592 -export type AppProps = Omit & { +export type AppProps = Omit>, "Component"> & { Component: NextAppProps["Component"] & { requiresLicense?: boolean; isThemeSupported?: boolean | ((arg: { router: NextRouter }) => boolean); getLayout?: (page: React.ReactElement, router: NextRouter) => ReactNode; }; + /** Will be defined only is there was an error */ err?: Error; }; @@ -77,6 +79,7 @@ const AppProviders = (props: AppPropsWithChildren) => { {/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */} { + const isNonPagePathPrefix = /^\/(?:_next|api)\//; + const isFile = /\..*$/; + const { pathname } = url; + return !isNonPagePathPrefix.test(pathname) && !isFile.test(pathname); +}; + +export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) { + if (!req) { + return { nonce: undefined }; + } + const existingNonce = req.headers["x-nonce"]; + if (existingNonce) { + const existingNoneParsed = z.string().safeParse(existingNonce); + return { nonce: existingNoneParsed.success ? existingNoneParsed.data : "" }; + } + if (!req.url) { + return { nonce: undefined }; + } + const CSP_POLICY = process.env.CSP_POLICY; + const cspEnabledForInstance = CSP_POLICY; + const nonce = crypto.randomBytes(16).toString("base64"); + + 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 + req.headers["x-nonce"] = nonce; + + if (res) { + res.setHeader( + req.headers["x-csp-enforce"] === "true" + ? "Content-Security-Policy" + : "Content-Security-Policy-Report-Only", + getCspPolicy(nonce) + .replace(/\s{2,}/g, " ") + .trim() + ); + } + + return { nonce }; +} diff --git a/apps/web/lib/withNonce.tsx b/apps/web/lib/withNonce.tsx new file mode 100644 index 0000000000..a07f901b8f --- /dev/null +++ b/apps/web/lib/withNonce.tsx @@ -0,0 +1,41 @@ +import { GetServerSideProps, GetServerSidePropsContext } from "next"; + +import { csp } from "@lib/csp"; + +export type WithNonceProps = { + 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(getServerSideProps: GetServerSideProps) { + return async (context: GetServerSidePropsContext) => { + const ssrResponse = await getServerSideProps(context); + 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; + + if (!("props" in ssrResponse)) { + return ssrResponse; + } + + // 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, + }, + }; + }; +} diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 4be198227c..4fcc97add5 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -36,11 +36,28 @@ const middleware: NextMiddleware = async (req) => { return NextResponse.rewrite(url); } + if (url.pathname.startsWith("/auth/login")) { + const moreHeaders = new Headers(); + // Use this header to actually enforce CSP, otherwise it is running in Report Only mode on all pages. + moreHeaders.set("x-csp-enforce", "true"); + return NextResponse.next({ + request: { + headers: moreHeaders, + }, + }); + } + return NextResponse.next(); }; export const config = { - matcher: ["/api/collect-events/:path*", "/api/auth/:path*", "/apps/routing_forms/:path*", "/:path*/embed"], + matcher: [ + "/api/collect-events/:path*", + "/api/auth/:path*", + "/apps/routing_forms/:path*", + "/:path*/embed", + "/auth/login", + ], }; export default collectEvents({ diff --git a/apps/web/next.config.js b/apps/web/next.config.js index c3afcc7ac6..b76ea1b273 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -35,6 +35,12 @@ if (!process.env.NEXT_PUBLIC_WEBSITE_URL) { process.env.NEXT_PUBLIC_WEBSITE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL; } +if (process.env.CSP_POLICY === "strict" && process.env.NODE_ENV === "production") { + throw new Error( + "Strict CSP policy(for style-src) is not yet supported in production. You can experiment with it in Dev Mode" + ); +} + if (!process.env.EMAIL_FROM) { console.warn( "\x1b[33mwarn", @@ -188,6 +194,19 @@ const nextConfig = { }, ], }, + { + source: "/:path*", + headers: [ + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + ], + }, ]; }, async redirects() { diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index f3d7a12b84..053a2a3ea9 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -29,13 +29,27 @@ function MyApp(props: AppProps) { } else if (router.pathname === "/500") { pageStatus = "500"; } + + // 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); return ( - +