From 30c0e6d1d7d95bed011e79db9616bf2e49ea54ce Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Tue, 7 Feb 2023 04:20:08 +0530 Subject: [PATCH 1/3] Beginning of Strict CSP Compliance (#6841) * Add CSP Support and enable it initially for Login page * Update README * Make sure that CSP is not enabled if CSP_POLICY isnt set * Add a new value for x-csp header that tells if instance has opted-in to CSP or not * Add more src to CSP * Fix typo in header name * Remove duplicate headers fn * Add https://eu.ui-avatars.com/api/ * Add CSP_POLICY to env.example --- .env.example | 3 + README.md | 2 + apps/web/lib/app-providers.tsx | 5 +- apps/web/lib/csp.ts | 83 +++++++++++++++++++ apps/web/lib/withNonce.tsx | 41 +++++++++ apps/web/middleware.ts | 19 ++++- apps/web/next.config.js | 19 +++++ apps/web/pages/_app.tsx | 16 +++- apps/web/pages/_document.tsx | 67 ++++----------- apps/web/pages/auth/login.tsx | 11 ++- apps/web/public/embed-init-iframe.js | 26 ++++++ .../embeds/embed-core/src/embed-iframe.ts | 1 - packages/types/environment.d.ts | 5 ++ turbo.json | 3 +- 14 files changed, 243 insertions(+), 58 deletions(-) create mode 100644 apps/web/lib/csp.ts create mode 100644 apps/web/lib/withNonce.tsx create mode 100644 apps/web/public/embed-init-iframe.js diff --git a/.env.example b/.env.example index ebe85373858b6e..cffb8c02d192d7 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 69454c9faeee71..4b8ffab14e43d7 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 276a2721ded0f5..78b43d3d59964c 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 00000000000000..a07f901b8ffab3 --- /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 4be198227c3565..4fcc97add528ef 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 c3afcc7ac6a339..b76ea1b273436d 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 f3d7a12b844e27..053a2a3ea9160e 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 ( - +