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:
parent
390967990a
commit
30c0e6d1d7
|
@ -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=
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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({
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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}'` }}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -19,7 +19,6 @@ declare global {
|
|||
resetEmbedStatus: () => void;
|
||||
getEmbedNamespace: () => string | null;
|
||||
getEmbedTheme: () => "dark" | "light" | null;
|
||||
isPageOptimizedForEmbed: (calLink: string) => boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -69,7 +69,8 @@
|
|||
"$DATOCMS_GRAPHQL_ENDPOINT",
|
||||
"$DATOCMS_API_TOKEN",
|
||||
"$STRIPE_SUPPORT_TABLE",
|
||||
"$ENVIRONMENT_URL"
|
||||
"$ENVIRONMENT_URL",
|
||||
"$CSP_POLICY"
|
||||
],
|
||||
"outputs": [".next/**"]
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue
Block a user