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
This commit is contained in:
Hariom Balhara 2023-02-07 04:20:08 +05:30 committed by GitHub
parent 390967990a
commit 30c0e6d1d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 243 additions and 58 deletions

View File

@ -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=

File diff suppressed because one or more lines are too long

View File

@ -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<NextJsAppProps<SSRConfig> & { children: React.ReactNode }>(
({ children }) => <>{children}</>
);
// Workaround for https://github.com/vercel/next.js/issues/8592
export type AppProps = Omit<NextAppProps, "Component"> & {
export type AppProps = Omit<NextAppProps<WithNonceProps & Record<string, unknown>>, "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) => {
<TooltipProvider>
{/* 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 */}
<ThemeProvider
nonce={props.pageProps.nonce}
enableColorScheme={false}
storageKey={storageKey}
forcedTheme={forcedTheme}

83
apps/web/lib/csp.ts Normal file
View File

@ -0,0 +1,83 @@
import crypto from "crypto";
import { IncomingMessage, OutgoingMessage } from "http";
import { z } from "zod";
import { IS_PRODUCTION } from "@calcom/lib/constants";
function getCspPolicy(nonce: string) {
//TODO: Do we need to explicitly define it in turbo.json
const CSP_POLICY = process.env.CSP_POLICY;
// Note: "non-strict" policy only allows inline styles otherwise it's the same as "strict"
// We can remove 'unsafe-inline' from style-src when we add nonces to all style tags
// Maybe see how @next-safe/middleware does it if it's supported.
const useNonStrictPolicy = CSP_POLICY === "non-strict";
return `
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
"'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:"
};
object-src 'none';
base-uri 'none';
child-src app.cal.com;
style-src 'self' ${
IS_PRODUCTION ? (useNonStrictPolicy ? "'unsafe-inline'" : "") : "'unsafe-inline'"
} app.cal.com;
font-src 'self';
img-src 'self' https://www.gravatar.com https://img.youtube.com https://eu.ui-avatars.com/api/ data:
`;
}
// 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);
};
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 };
}

View File

@ -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,
},
};
};
}

View File

@ -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({

View File

@ -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() {

View File

@ -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 (
<AppProviders {...props}>
<AppProviders {...providerProps}>
<DefaultSeo {...seoConfig.defaultNextSeo} />
<I18nLanguageHandler />
<Script
nonce={nonce}
id="page-status"
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>

View File

@ -1,58 +1,36 @@
import Document, { DocumentContext, Head, Html, Main, NextScript, DocumentProps } from "next/document";
import Script from "next/script";
import { z } from "zod";
import { getDirFromLang } from "@calcom/lib/i18n";
import { csp } from "@lib/csp";
type Props = Record<string, unknown> & DocumentProps;
function toRunBeforeReactOnClient() {
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
/* Iframe Name */
window.name.includes("cal-embed");
window.isEmbed = () => {
// Once an embed mode always an embed mode
return calEmbedMode;
};
window.resetEmbedStatus = () => {
try {
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
window.sessionStorage.removeItem("calEmbedMode");
} catch (e) {}
};
window.getEmbedTheme = () => {
const url = new URL(document.URL);
return url.searchParams.get("theme") as "dark" | "light";
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};
window.isPageOptimizedForEmbed = () => {
// Those pages are considered optimized, which know at backend that they are rendering for embed.
// Such pages can be shown straightaway without a loader for a better embed experience
return location.pathname.includes("forms/");
};
}
class MyDocument extends Document<Props> {
static async getInitialProps(ctx: DocumentContext) {
const { nonce } = csp(ctx.req || null, ctx.res || null);
if (!process.env.CSP_POLICY) {
ctx.res?.setHeader("x-csp", "not-opted-in");
} else if (!ctx.res?.getHeader("x-csp")) {
// If x-csp not set by gSSP, then it's initialPropsOnly
ctx.res?.setHeader("x-csp", "initialPropsOnly");
}
const isEmbed = ctx.asPath?.includes("/embed") || ctx.asPath?.includes("embedType=");
const initialProps = await Document.getInitialProps(ctx);
return { isEmbed, ...initialProps };
return { isEmbed, nonce, ...initialProps };
}
render() {
const { locale } = this.props.__NEXT_DATA__;
const { isEmbed } = this.props;
const nonceParsed = z.string().safeParse(this.props.nonce);
const nonce = nonceParsed.success ? nonceParsed.data : "";
const dir = getDirFromLang(locale);
return (
<Html lang={locale} dir={dir}>
<Head>
<Head nonce={nonce}>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
@ -68,22 +46,13 @@ class MyDocument extends Document<Props> {
crossOrigin="anonymous"
/>
<link rel="preload" href="/fonts/cal.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
{/* Define isEmbed here so that it can be shared with App(embed-iframe) as well as the following code to change background and hide body
Persist the embed mode in sessionStorage because query param might get lost during browsing.
*/}
<Script
id="run-before-client"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `(${toRunBeforeReactOnClient.toString()})()`,
}}
/>
<Script src="/embed-init-iframe.js" strategy="beforeInteractive" />
</Head>
<body
className="dark:bg-darkgray-50 desktop-transparent bg-gray-100 antialiased"
style={
this.props.isEmbed
isEmbed
? {
background: "transparent",
// Keep the embed hidden till parent initializes and
@ -95,7 +64,7 @@ class MyDocument extends Document<Props> {
: {}
}>
<Main />
<NextScript />
<NextScript nonce={nonce} />
</body>
</Html>
);

View File

@ -19,6 +19,7 @@ import { Alert, Button, EmailField, PasswordField } from "@calcom/ui";
import { FiArrowLeft } from "@calcom/ui/components/icon";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import withNonce, { WithNonceProps } from "@lib/withNonce";
import AddToHomescreen from "@components/AddToHomescreen";
import TwoFactor from "@components/auth/TwoFactor";
@ -40,7 +41,7 @@ export default function Login({
isSAMLLoginEnabled,
samlTenantID,
samlProductID,
}: inferSSRProps<typeof getServerSideProps>) {
}: inferSSRProps<typeof _getServerSideProps> & WithNonceProps) {
const { t } = useLocale();
const router = useRouter();
const methods = useForm<LoginValues>();
@ -205,7 +206,8 @@ export default function Login({
);
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
// TODO: Once we understand how to retrieve prop types automatically from getServerSideProps, remove this temporary variable
const _getServerSideProps = async function getServerSideProps(context: GetServerSidePropsContext) {
const { req } = context;
const session = await getSession({ req });
const ssr = await ssrInit(context);
@ -229,7 +231,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
};
}
return {
props: {
csrfToken: await getCsrfToken(context),
@ -240,4 +241,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
samlProductID,
},
};
}
};
export const getServerSideProps = withNonce(_getServerSideProps);

View File

@ -0,0 +1,26 @@
const calEmbedMode = typeof new URL(document.URL).searchParams.get("embed") === "string";
/* Iframe Name */
window.name.includes("cal-embed");
window.isEmbed = () => {
// Once an embed mode always an embed mode
return calEmbedMode;
};
window.resetEmbedStatus = () => {
try {
// eslint-disable-next-line @calcom/eslint/avoid-web-storage
window.sessionStorage.removeItem("calEmbedMode");
} catch (e) {}
};
window.getEmbedTheme = () => {
const url = new URL(document.URL);
return url.searchParams.get("theme");
};
window.getEmbedNamespace = () => {
const url = new URL(document.URL);
const namespace = url.searchParams.get("embed");
return namespace;
};

View File

@ -19,7 +19,6 @@ declare global {
resetEmbedStatus: () => void;
getEmbedNamespace: () => string | null;
getEmbedTheme: () => "dark" | "light" | null;
isPageOptimizedForEmbed: (calLink: string) => boolean;
}
}

View File

@ -53,5 +53,10 @@ declare namespace NodeJS {
readonly NEXT_PUBLIC_APP_NAME: string | "Cal";
readonly NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS: string | "help@cal.com";
readonly NEXT_PUBLIC_COMPANY_NAME: string | "Cal.com, Inc.";
/**
* "strict" -> Strict CSP
* "non-strict" -> Strict CSP except the usage of unsafe-inline for `style-src`
*/
readonly CSP_POLICY: "strict" | "non-strict";
}
}

View File

@ -69,7 +69,8 @@
"$DATOCMS_GRAPHQL_ENDPOINT",
"$DATOCMS_API_TOKEN",
"$STRIPE_SUPPORT_TABLE",
"$ENVIRONMENT_URL"
"$ENVIRONMENT_URL",
"$CSP_POLICY"
],
"outputs": [".next/**"]
},