I'm trying to implement a content security policy on my website. I am using Next.js and MUI as my component library.
Right now I'm generating a nonce within _document and my server side emotion cache has the nonce passed into it so that server side inline styles will render. However, I am also using a client side emotion cache in _app and I need to pass it the same nonce. Can anyone point me in the right direction on how to pass that nonce that is generated on the server on _document to _app on the client?
_app.js
import PropTypes from 'prop-types'
import { ThemeProvider } from '@mui/material/styles'
import { breakPoints } from '../helpers/theme'
import { SessionProvider } from 'next-auth/react'
import { useRouter } from 'next/router'
import Script from 'next/script'
import Auth from 'components/auth'
import { IntlProvider } from 'react-intl'
import localeMap from '../helpers/locale-map'
import { lightTheme } from 'helpers/theme'
import React, { useEffect, useRef, useState } from 'react'
import { QueryClient, QueryClientProvider } from 'react-query'
import { Hydrate } from 'react-query/hydration'
import getConfig from 'next/config'
import { Settings as luxonSettings } from 'luxon'
import { datadogLogs } from '@datadog/browser-logs'
import { datadogRum } from '@datadog/browser-rum'
import StoreProvider from '../store'
import { ReactQueryDevtools } from 'react-query/devtools'
import NextJSApp from 'next/app'
import { GLOBAL } from '../helpers/constants'
import { CacheProvider } from '@emotion/react'
import createEmotionCache from '../helpers/emotion-cache'
import { DefaultSeo } from 'next-seo'
import {
generateCanonicalUrl,
generateLanguageAlternatesConfig,
} from 'helpers/seo'
import CCPABanner from 'components/home/ccpa-banner'
import { userCountryFromHeaders } from 'helpers/localization'
import Head from 'next/head'
import { nanoid } from 'nanoid'
const { publicRuntimeConfig } = getConfig()
if (
process.env.NODE_ENV === 'development' &&
publicRuntimeConfig.mockApi.enabled &&
typeof window !== 'undefined'
) {
const { default: makeServer } = require('../queries/mock-api/server')
makeServer()
}
if (process.env.NODE_ENV === 'production') {
// initialize browser-logs for DataDog only for production
datadogLogs.init(publicRuntimeConfig.datadogLogs)
datadogRum.init(publicRuntimeConfig.datadogRum)
datadogRum.startSessionReplayRecording()
}
export function reportWebVitals({ name, value }) {
switch (name) {
case 'FCP': // https://web.dev/fcp/
datadogRum.addTiming('first-contentful-paint', value)
break
case 'LCP': // https://web.dev/lcp/
datadogRum.addTiming('largest-contentful-paint', value)
break
case 'CLS': //https://web.dev/cls/
datadogRum.addTiming('cumulative-layout-shift', value)
break
case 'FID': // https://web.dev/fid/
datadogRum.addTiming('first-input-delay', value)
break
case 'TTFB': // https://web.dev/ttfb/
datadogRum.addTiming('time-to-first-byte', value)
break
case 'Next.js-hydration':
datadogRum.addTiming('hydration', value)
break
case 'Next.js-route-change-to-render':
datadogRum.addTiming('route-changed', value)
break
case 'Next.js-render':
datadogRum.addTiming('render-finished', value)
break
default:
break
}
}
// Client-side cache, shared for the whole session of the user in the browser.
// Use the <Provider> to improve performance and allow components that call
// `useSession()` anywhere in your application to access the `session` object.
function App({
Component,
pageProps,
globalData,
localizedMessages,
ccpaCookiePresent,
nonce,
}) {
const queryClientRef = useRef()
const storeRef = useRef({
[GLOBAL]: globalData,
})
const router = useRouter()
const { locale, defaultLocale } = router
const [showCCPABanner, setShowCCPABanner] = useState(!ccpaCookiePresent)
const isUSUser = globalData?.userCountry === 'US'
luxonSettings.defaultLocale = locale
if (!queryClientRef.current) {
queryClientRef.current = new QueryClient(publicRuntimeConfig.queryClient)
}
const emotionCache = createEmotionCache(nonce)
const Layout = Component.Layout ? Component.Layout : React.Fragment
const AuthWrapper = Component.auth ? Auth : React.Fragment
const setCCPACookie = () => {
const currentDate = new Date()
const expirationDate = new Date(
currentDate.setMonth(currentDate.getMonth() + 6),
)
document.cookie = `CCPABannerShown=1; expires=${expirationDate}`
setShowCCPABanner(false)
}
useEffect(() => {
router.events.on('routeChangeComplete', (url) => {
global.analytics.page(url)
})
}, [router.events])
// useEffect(() => {
// TagManager.initialize({ gtmId: publicRuntimeConfig.gtm.id })
// }, [])
const languageAlternatesConfig = generateLanguageAlternatesConfig(router)
const canonicalUrl = generateCanonicalUrl(router)
//scaling for tablet and mobile views less than medium breakpoint width
const screenWidth = typeof window !== 'undefined' && window.outerWidth
const mediumBreakPoint = breakPoints.values.md
const screenScaling =
Component.NeedsMobileScaling &&
screenWidth &&
screenWidth < mediumBreakPoint
? screenWidth / mediumBreakPoint
: 1
return (
<>
<Head>
<meta
name="viewport"
content={`viewport-fit=cover, initial-scale=${screenScaling}, minimum-scale=${screenScaling}`}
/>
</Head>
<Script
strategy="lazyOnload"
src={`https://connect.facebook.net/en_US/sdk.js#version=v11.0&appId=${publicRuntimeConfig.facebook.clientId}&cookie=true&xfbml=true&status=true`}
/>
<Script
strategy="afterInteractive"
src={`https://consent.truste.com/notice?domain=myfitnesspal.com&c=teconsent&text=true&language=${locale}`}
/>
<SessionProvider
{...publicRuntimeConfig.sessionProvider}
session={pageProps.session}
>
<IntlProvider
locale={locale}
defaultLocale={defaultLocale}
messages={localizedMessages}
>
<QueryClientProvider client={queryClientRef.current}>
<Hydrate state={pageProps.dehydratedState}>
<CacheProvider value={emotionCache}>
<ThemeProvider theme={lightTheme}>
<StoreProvider initialState={storeRef.current}>
<DefaultSeo
defaultTitle={`${localizedMessages['defaultPageTitle']} | MyFitnessPal.com`}
titleTemplate="%s | MyFitnessPal.com"
dangerouslySetAllPagesToNoIndex={
publicRuntimeConfig.noIndexMetaTag
}
description={
localizedMessages['app.sharedPageDescription']
}
languageAlternates={languageAlternatesConfig}
canonical={canonicalUrl}
facebook={{
appId: publicRuntimeConfig.facebook.clientId,
}}
/>
<Layout>
<AuthWrapper>
<Component {...pageProps} />
</AuthWrapper>
{showCCPABanner && (
<CCPABanner
onClose={setCCPACookie}
isUSUser={isUSUser}
/>
)}
</Layout>
</StoreProvider>
</ThemeProvider>
</CacheProvider>
<ReactQueryDevtools position="bottom-right" />
</Hydrate>
</QueryClientProvider>
</IntlProvider>
</SessionProvider>
</>
)
}
const getClientOS = (headerUserAgent) => {
if (/iOS|iPhone|iPad/.test(headerUserAgent)) return 'iOS'
if (/Android/.test(headerUserAgent)) return 'AndroidOS'
return 'Web'
}
App.getInitialProps = async (appContext) => {
let nonce
if (appContext.ctx.req) {
nonce = nanoid()
appContext.ctx.req.nonce = nonce
}
console.log(nonce, 'nonce from appContext _app')
const appProps = await NextJSApp.getInitialProps(appContext)
const headers = appContext.ctx.req?.headers
const ccpaCookiePresent = !!appContext.ctx.req?.cookies['CCPABannerShown']
const locale = appContext.router.locale
const mappedLocale = localeMap[locale] || 'en' // This mapping is necessary because our locales do not exactly match Smartling's enforced file naming conventions.
const localizedMessages = await import(`../locales/${mappedLocale}.json`)
if (headers) {
const client = getClientOS(headers['user-agent'])
const userCountry = userCountryFromHeaders(appContext.ctx)
appProps['globalData'] = { userCountry, client }
}
return {
...appProps,
ccpaCookiePresent,
localizedMessages,
nonce,
}
}
App.propTypes = {
Component: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
emotionCache: PropTypes.object,
pageProps: PropTypes.object,
globalData: PropTypes.object,
ccpaCookiePresent: PropTypes.bool,
localizedMessages: PropTypes.object,
nonce: PropTypes.string,
}
export default App
_document.js
import Document, { Head, Html, Main, NextScript } from 'next/document'
import React from 'react'
import createEmotionServer from '@emotion/server/create-instance'
import createEmotionCache from '../../src/helpers/emotion-cache'
import getConfig from 'next/config'
import * as snippet from '@segment/snippet'
import { getUserIsPremium } from 'helpers/server'
import { QueryClient } from 'react-query'
import { getUser } from 'queries/user/queries'
import { getActiveSubscription } from 'queries/subscription/queries'
import { getSession } from 'next-auth/react'
import { prefetch } from 'queries'
export default class MyDocument extends Document {
render() {
const { publicRuntimeConfig } = getConfig()
const { minified, options } = publicRuntimeConfig.segment
const segmentSnippet = snippet[minified ? 'min' : 'max'](options)
return (
<Html>
<Head>
<link rel="icon" type="image/png" href="/favicon.png" />
<link
rel="stylesheet preload"
href="https://web-assets.myfitnesspal.com/fonts/neue-plak-ua.css"
as="style"
/>
{/* Our GTM package react-gtm-module requires this script to access the gtag function referenced in the GTM script. Please see https://stackoverflow.com/questions/61041158/google-analytics-webapp-event-config-error-gtag-is-not-defined for more info. */}
{/* <script
dangerouslySetInnerHTML={{
__html: ` window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}`,
}}
/> */}
{/* <script
id="gtmScript"
nonce={this.props.nonce}
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;var n=d.querySelector('[nonce]');
n&&j.setAttribute('nonce',n.nonce||n.getAttribute('nonce'));f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${publicRuntimeConfig.gtm.id}');`,
}}
></script> */}
{this.props.shouldLoadAdScript && (
<>
<script
async
nonce={this.props.nonce}
src="https://securepubads.g.doubleclick.net/tag/js/gpt.js"
/>
<script
nonce={this.props.nonce}
dangerouslySetInnerHTML={{
__html: `window.googletag = window.googletag || {cmd: []};
googletag.cmd.push(function() {
googletag.pubads().setForceSafeFrame(true);
});`,
}}
/>
</>
)}
<script
nonce={this.props.nonce}
dangerouslySetInnerHTML={{ __html: segmentSnippet }}
/>
<meta property="csp-nonce" content={this.props.nonce} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG).
MyDocument.getInitialProps = async (ctx) => {
// Resolution order
//
// On the server:
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. document.getInitialProps
// 4. app.render
// 5. page.render
// 6. document.render
//
// On the server with error:
// 1. document.getInitialProps
// 2. app.render
// 3. page.render
// 4. document.render
//
// On the client
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. app.render
// 4. page.render
const queryClient = new QueryClient()
const session = await getSession(ctx)
if (session) {
await Promise.all([
await queryClient.prefetchQuery(
'subscription',
prefetch('subscription', getActiveSubscription, ctx),
),
await queryClient.prefetchQuery('user', prefetch('user', getUser, ctx)),
])
}
const userIsPremium = getUserIsPremium(queryClient)
const isLoggedIn = !!ctx.req.cookies._mfp_session
const shouldLoadAdScript = isLoggedIn && !userIsPremium
const originalRenderPage = ctx.renderPage
console.log(ctx.req.nonce, 'nonce in document')
const nonce = ctx.req.nonce
const docProps = await ctx.defaultGetInitialProps(ctx)
let contentSecurityPolicy = ''
if (process.env.NODE_ENV === 'production') {
contentSecurityPolicy = `default-src 'self'; style-src 'self' 'nonce-${nonce}' web-assets.myfitnesspal.com fonts.googleapis.com; connect-src *; img-src * data:; font-src web-assets.myfitnesspal.com fonts.gstatic.com; frame-src consent-pref.trustarc.com bid.g.doubleclick.net *.googlesyndication.com www.google.com; script-src 'self' 'nonce-${nonce}' web-main-assets.myfitnesspal.com fonts.gstatic.com cdn.ampproject.org secure.cdn.fastclick.net *.googlesyndication.com www.googletagservices.com z.moatads.com www.googletagmanager.com connect.facebook.net cdn.segment.com consent.truste.com consent.trustarc.com http://consent.trustarc.com www.google.com www.gstatic.com/recaptcha; prefetch-src web-main-assets.myfitnesspal.com`
} else {
// react-refresh needs 'unsafe-eval'
// Next.js needs 'unsafe-inline' during development https://github.com/vercel/next.js/blob/canary/packages/next/client/dev/fouc.js
// Specifying 'nonce' makes a modern browsers ignore 'unsafe-inline'
contentSecurityPolicy = `default-src 'self'; style-src 'unsafe-inline' web-assets.myfitnesspal.com; connect-src *; img-src * data:; font-src web-assets.myfitnesspal.com; frame-src consent-pref.trustarc.com bid.g.doubleclick.net *.googlesyndication.com www.google.com; script-src 'self' 'nonce-${nonce}' 'unsafe-eval' *.googlesyndication.com web-main-assets.myfitnesspal.com fonts.gstatic.com cdn.ampproject.org secure.cdn.fastclick.net www.googletagservices.com z.moatads.com www.googletagmanager.com connect.facebook.net cdn.segment.com consent.truste.com consent.trustarc.com http://consent.trustarc.com www.google.com; prefetch-src web-main-assets.myfitnesspal.com`
}
ctx.res.setHeader('Content-Security-Policy', contentSecurityPolicy)
// console.log(ctx.res, 'nonce')
// You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
// However, be aware that it can have global side effects.
const cache = createEmotionCache(nonce)
const { extractCriticalToChunks } = createEmotionServer(cache)
ctx.renderPage = () =>
originalRenderPage({
// eslint-disable-next-line react/display-name
enhanceApp: (App) => (props) => <App emotionCache={cache} {...props} />,
})
const initialProps = await Document.getInitialProps(ctx)
// This is important. It prevents emotion to render invalid HTML.
// See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
const emotionStyles = extractCriticalToChunks(initialProps.html)
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(' ')}`}
key={style.key}
nonce={nonce}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
/>
))
return {
...initialProps,
...docProps,
shouldLoadAdScript,
nonce,
// Styles fragment is rendered after the app and page rendering finish.
styles: [
...React.Children.toArray(initialProps.styles),
...emotionStyleTags,
],
}
}