@next-safe/middleware
Strict Content-Security-Policy (CSP) for Next.js
Works for hybrid apps and supports pages with any data fetching method.
Always sets CSP by HTTP Response header and enables easy setup of reporting.
I want this: How can I get started?
What
This package strives to make the setup and deployment of a strict Content Security Policy (CSP) with Next.js an easy task. The design approach that makes this possible requires Next.js Middleware, which has been introduced as Beta in Next.js 12 and is stable since Next.js 12.2.
This package handles all strict CSP conundrums for you and works for:
-
pages with
getStaticProps
- Hash-based -
pages with
getServerSideProps
- Nonce-based -
pages with
getStaticProps
+revalidate
(ISR) - Hash-based
This package always sets CSP as HTTP response header. That enables violation reporting and report-only mode even for static pages. Plus, it provides a middleware and API handlers that make the setup of CSP violation reporting very easy.
Why
Configuring and maintaining a Content-Security-Policy (CSP) can be a tedious and error prone task. Furthermore, classic CSPs with a whitelist approach don't give you the security you might think you get from them, because in a lot of cases, they are automatically bypassable.
There is a much better option: a Hash-based/Nonce-based strict CSP.
Such CSPs provide much better security and have always the same structure, so they don't need the maintenance that whitelist CSPs need, once they've been set up properly. But this setup is usually a a very big issue with Next.js (and with all web frameworks in general).
This is where this package comes in: To make this setup easy, convenient and a lot less error-prone.
More resources on (strict) CSPs:
-
The best overview on strict CSPs: https://web.dev/strict-csp/
-
Great slides from a conference talk, has lots of insights and field data: https://static.sched.com/hosted_files/locomocosec2019/db/CSP%20-%20A%20Successful%20Mess%20Between%20Hardening%20and%20Mitigation%20%281%29.pdf
-
Great view on CSPs from an attacker's perspective: https://book.hacktricks.xyz/pentesting-web/content-security-policy-csp-bypass
-
Good explanation of the
strict-dynamic
keyword: https://content-security-policy.com/strict-dynamic/ -
Indispensible for testing: The CSP Evaluator Extension for Google Chrome
Getting started
install @next-safe/middleware
from NPM
npm -i @next-safe/middleware
yarn add @next-safe/middleware
create a file middleware.js
in your Next.js project folder (or pages/_middleware.js
for 12 <= your Next.js version < 12.2):
// middleware.js
import { chainMatch, isPageRequest, csp, strictDynamic } from '@next-safe/middleware';
const securityMiddleware = [
csp({
// your CSP base configuration with IntelliSense
// single quotes for values like 'self' are automatic
directives: {
'img-src': ['self', 'data:', 'https://images.unsplash.com'],
'font-src': ['self', 'https://fonts.gstatic.com'],
},
}),
strictDynamic(),
];
export default chainMatch(isPageRequest)(...securityMiddleware);
create a file pages/_document.js
in your Next.js project folder:
// pages/_document.js
import {
getCspInitialProps,
provideComponents,
} from '@next-safe/middleware/dist/document';
import Document, { Html, Main } from 'next/document';
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const initialProps = await getCspInitialProps({ ctx });
return initialProps;
}
render() {
const { Head, NextScript } = provideComponents(this.props);
return (
<Html>
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
Thats it. You should be all set now with a strict CSP for your Next.js app!
Default CSP directives
I found this to be the most minimal yet sensible defaults as base for a strict CSP:
const defaults = {
directives: {
'default-src': ['self'],
'object-src': ['none'],
'base-uri': ['none'],
},
isDev: process.env.NODE_ENV === 'development',
reportOnly: !!process.env.CSP_REPORT_ONLY,
};
You can override a directive's default by setting values or unset it with an empty array:
const cspMiddleware = csp({
directives: {
'default-src': [],
},
});
Hash-based strict CSP for pages with Incremental Static Regeneration (ISR)
Add the following code to the top of every route with getStaticProps
that uses revalidate
(including res.revalidate
or res.unstable_revalidate
(< 12.2) of on-demand ISR, available since Next 12.1):
export const config = {
unstable_includeFiles: ['.next/static/chunks/**/*.js'],
};
TypeScript
This package is written with 100% TypeScript and provides typings for full IntelliSense with CSP and middleware configuration. The typing for CSP directives is borrowed from the CSP integration of SvelteKit.
Furthermore, most middlewares, functions, parameters and types have JSDoc that includes examples, explanations and links to related resources.
How to set up CSP violation reporting?
add the reporting
middleware to your middleware chain in middleware.js
or pages/_middleware.js
:
// middleware.js
import {
chainMatch,
isPageRequest
csp,
reporting,
strictDynamic,
strictInlineStyles,
} from '@next-safe/middleware';
const securityMiddleware = [
csp(),
strictDynamic(),
reporting({
csp: {
reportUri: '/api/reporting',
},
reportTo: {
max_age: 1800,
endpoints: [
{
url: '/api/reporting',
},
],
},
}),
];
export default chainMatch(isPageRequest)(...securityMiddleware);
then, set up the reporting endpoint in pages/api/reporting.js
:
import { reporting } from '@next-safe/middleware/dist/api';
/** @type {import('@next-safe/middleware/dist/api').Reporter} */
const consoleLogReporter = (data) =>
console.log(JSON.stringify(data, undefined, 2));
export default reporting(consoleLogReporter);
CSP violation reports will be send to this endpoint, both from report-uri
and report-to
directives. The data shape of reports is slightly different between both directives. The API handler parses the data into a discriminated union and passes it to all reporter functions. You can pattern-match the data there with IntelliSense support.
Ingest CSP violation reports to Sentry
If you use Sentry, there is a convenient helper sentryCspReporterForEndpoint
to create a reporter, that ingests all CSP violations into your Sentry project:
// pages/api/reporting.js
import {
reporting,
sentryCspReporterForEndpoint,
} from '@next-safe/middleware/dist/api';
// lookup at https://docs.sentry.io/product/security-policy-reporting/
const sentryCspEndpoint = process.env.SENTRY_CSP_ENDPOINT;
const sentryCspReporter = sentryCspReporterForEndpoint(sentryCspEndpoint);
export default reporting(sentryCspReporter);
Sentry only supports the data format of the report-uri
directive. It can't receive violation reports in report-to
format (Google Chrome only serves report-to
). sentryCspReporterForEndpoint
does the necessary conversion, so you will receive violation reports from all major browsers in Sentry.
How to add custom (inline) scripts that work with strict CSP?
Just add them with next/script
on the pages where you need them. If you want to include a script in all pages, add them to your pages/app.js
.
The following files serve as examples for script usage:
How this behaves behind the scenes
<Script>
's with strategies afterInteractive
and lazyOnLoad
will become trusted by transitive trust propagation of strict-dynamic
and so will be all scripts that they load dynamically, etc. That should cover the majority of use cases.
<Script>
's with strategy beforeInteractive
you place in _document.js
and inline <script>
's you place as children of <Head>
in _document.js
are automatically picked up for strict CSP by this package.
What this package will do with such scripts, depends:
Pages with getServerSideProps (Nonce-based)
the script will eventually receive the nonce.
Pages with getStaticProps (Hash-based)
-
The script loads from
src
and has an integrity attribute: The integrity attribute/hash will be picked up for CSP. Don't forget to set{crossOrigin: "anonymous"}
innext.config.js
, else the SRI validation will fail. -
The script loads from
src
and doesn't have an integrity attribute: The script will be replaced by an inline proxy script that loads the script. The hash of this proxy script will be picked up for CSP. The actual script eventually becomes trusted by transitive trust propagation ofstrict-dynamic
. -
The script is an inline script: The inline code of the script will be hashed, the hash will be set as integrity attribute of the inline script and the hash will be picked up by CSP.
unsafe-inline
keyword in style-src
(with a CSS-in-JS framework)?
How to avoid the This package tries to provide a best effort solution to this, with a strictInlineStyles
middleware. The e2e test app of this package comes with a setup that uses both twin.macro + Stitches and Mantine (uses emotion under the hood) without unsafe-inline
in style-src
. The following files serve as the references for such setups:
However, in the end this package might not always be able to solve this, as this issue is highly dependent on the actual CSS-in-JS framework and 3rd party libs (dynamically inject inline styles?) you use.
How to set security headers other than CSP?
For this you can use the nextSafe
middleware that wraps the next-safe
package. Use it with CSP disabled and use the csp
middleware for your CSP configuration instead, like so:
// middleware.js
import {
chainMatch,
isPageRequest,
csp,
nextSafe,
strictDynamic,
} from '@next-safe/middleware';
const securityMiddleware = [
nextSafe({ disableCsp: true }),
csp(),
strictDynamic(),
];
export default chainMatch(isPageRequest)(...securityMiddleware);
The configuration options of the nextSafe
middleware are the same as documented at https://trezy.gitbook.io/next-safe/usage/configuration
How to compose with other middleware?
In your middleware.js
or pages/_middleware.js
you might potentially want to do more interesting things than just setting security headers. However, Next.js doesn't offer an idiomatic abstraction for composing middlewares yet.
That's why this package provides a minimal and simple abstraction for composing/chaining multiple middlewares within the middleware.js
file. For this, it provides a function chain
, that accepts an array of type ChainableMiddleware
:
type NextMiddlewareResult = NextResponse | Response | null | undefined | void;
// this is the Middleware spec of Next.js
type NextMiddleware = (
request: NextRequest,
event: NextFetchEvent
) => NextMiddlewareResult | Promise<NextMiddlewareResult>;
// this is the extended spec for chainable middleware this package uses
type ChainableMiddleware = (
...params: [
...spec: Parameters<NextMiddleware>,
res?: Response,
next?: (res: Response) => void
]
) => ReturnType<NextMiddleware>;
The ChainableMiddleware
interface is compatible with the Next.js spec (what needs to be exported from middleware.js
) and provides additional handles for the composition context with chain
:
req
: https://vercel.com/docs/concepts/functions/edge-functions#nextrequest
evt
: https://vercel.com/docs/concepts/functions/edge-functions#nextfetchevent
res
(in chain context): a continued response from further left in the chain. Middleware further right in the chain can modify it, by adding/changing headers, cookies etc.
next
(in chain context): a function to signal that middleware further right in the chain should continue execution with the passed response.
In chain context, the return value of ChainableMiddleware
is interpreted as follows:
- a middleware returns a response:
chain
gets terminated. Middlewares to its right in the chain don't execute - a middleware returns nothing:
chain
continues with the middleware to its right - a middleware passes a response to
next
and returns nothing:chain
continues with the middleware to its right, with the response available inres
parameter of the next middleware.
Here's an example to show how you can combine security middleware from this package with other middleware by using chain
:
// middleware.js
import {
chain,
chainMatch,
isPageRequest,
csp,
strictDynamic,
} from '@next-safe/middleware';
/** @type {import('@next-safe/middleware').ChainableMiddleware} */
const geoBlockMiddleware = (req) => {
const BLOCKED_COUNTRY = 'GB';
const country = req.geo.country || 'US';
if (country === BLOCKED_COUNTRY) {
const response = new Response('Blocked for legal reasons', { status: 451 });
// returning response terminates the chain
return response;
}
};
const securityMiddleware = [csp(), strictDynamic()];
// geoBlockMiddleware will run on all requests
// securityMiddleware will only run on requests
// that didn't get geo-blocked and only on requests for pages
export default chain(
geoBlockMiddleware,
chainMatch(isPageRequest)(...securityMiddleware)
);
Can CSP/middleware configuration depend on request data?
Yes. In fact every middleware of this package supports configuration with an (async) initializer function, that receives the request as 1st param (in req
), the currently set response of the middleware chain as 2nd (in res
) and for convenience, a uaParser
instance prepared with the user agent of the request as 3rd (from ua-parser-js
, prebundled and minified with this package, for IntelliSense install @types/ua-parser-js
in your project.
For example, you can use this capability to select different CSP configurations for different user agents:
// middleware.js
import {
chainMatch,
isPageRequest,
csp,
strictDynamic,
} from "@next-safe/middleware";
// CSP in always in report-only mode for Firefox
// and by env var for other browsers
const cspMiddleware = csp(async (req, res, uaParser) => {
const browserName = uaParser.getBrowser().name || "";
const reportOnly =
!!process.env.CSP_REPORT_ONLY || browserName.includes("Firefox");
return {
reportOnly,
};
});
const securityMiddleware = [cspMiddleware, strictDynamic()];
export default chainMatch(isPageRequest)(...securityMiddleware);;