Content Security Policy, inline scripts and Next.js
written by daniel in
frontend
on 29 Apr 2020
TL;DR Use CSP with nonce & strict-dynamic
to secure the origin of inline scripts.
Updated 2021-02-17 for Helmet v4
Next includes client-side scripts in <script>
tags in Document
, so usually, a script-src 'self';
would be sufficient.
However, I also needed to execute inline scripts, which meant that I either had to use unsafe-inline
(which is only marginally better than no CSP), a hash of the scripts’ content, or a nonce.
I went with nonce, and even though it turned out to be pretty straightforward in the end, it still ended up eating most of my afternoon today.
Setting up security headers
I used helmet
, an express
middleware that makes setting security headers easier.
The types make it convenient to see all the configuration options.
$ yarn add helmet
$ yarn add -D @types/helmet
Setting up CSP with nonce
The idea is to generate a random nonce on every request, send it to the browser in the CSP header and make sure all scripts have a matching value: <script nonce={nonce}>
.
See MDN for all possible CSP options.
'strict-dynamic'
tells the browser to trust any other code executed by already trusted code.
If you specify 'strict-dynamic'
, the browser will disregard other options such as 'self' *.3rdparty.com
.
Every script will then need to have the nonce.
import express from 'express'
import helmet from 'helmet'
import { v4 } from 'uuid'
const server = express()
const options = {
directives: {
'default-src': ["'self'"],
'script-src': [(req, res) => `'nonce-${res.locals.nonce}' 'strict-dynamic'`],
},
// ... more config options
}
// generate a nonce on every request and save it
server.use((req, res, next) => {
res.locals.nonce = v4()
helmet.contentSecurityPolicy(options)(req, res, next)
})
We will need to pass the nonce to Next as well as any other scripts.
Let’s create a custom Document
in pages/_document.tsx
, retrieve the nonce from context and pass it down:
import Document, {
Head,
Html,
Main,
NextScript,
DocumentContext,
} from 'next/document'
import { ServerResponse } from 'http'
type ResponseWithNonce = ServerResponse & { locals: { nonce?: string } }
type CustomDocumentProps = {
nonce?: string
}
class CustomDocument extends Document<CustomDocumentProps> {
static async getInitialProps(ctx: DocumentContext) {
// get the nonce from res.locals.nonce
const nonce = (ctx.res as ResponseWithNonce).locals.nonce
const initialProps = await Document.getInitialProps(ctx)
return { ...initialProps, nonce }
}
render() {
return (
<Html>
{/* pass it to Next Head */}
<Head nonce={this.props.nonce} />
<body>
<Main />
{/* pass it to Next scripts */}
<NextScript nonce={this.props.nonce} />
{/* as well as any other scripts */}
<script
src="https://trustworthy-conglomerate.com/vacuum.js"
nonce={this.props.nonce}
/>
</body>
</Html>
)
}
}
export default CustomDocument
Easier option
If you don’t need to execute inline scripts, you don’t need this nonce stuff.
It will suffice to specify trusted origins: script-src 'self' *.3rdparty.com;
.