sudolabs logo

3. 11. 2023

3 min read

Dynamic CSP in Next.js applications

Content Security Policy (CSP) is a crucial security feature that helps protect web applications from script injections and other potential security threats. Typically, CSP is implemented statically, in the head of the website, or HTTP response. In this blog post, we'll discuss the problem we encountered with this approach and the solution we devised to manage CSP dynamically within our Next.js application.

Jovan Blažek

In need of adapting CSP for real-time flexibility

In most cases, web applications are content with a static implementation of CSP, which provides protection against script injections. However, we faced a unique challenge in one of our projects. We needed a flexible solution that would allow us to change the CSP rules on the fly, without the need for constant redeployment. This became particularly cumbersome due to the integration of extended analytics tools and experimentation requirements.

To address this challenge, we came up with the idea of managing CSP rules directly within our application.

Our solution

Our solution involves storing CSP rules in a database and dynamically constructing the CSP header based on these rules.

We agreed on the following flow:

  1. Define the CSP rules for each source using the frontend dashboard.

  2. Store the data in a PostgreSQL database.

  3. On page request, build the CSP header from the rules provided by API.

This approach, however, presented several challenges, such as communication with the backend from the Next.js' middleware and the need to cache the results to avoid performance issues.

Storing CSP rules in the database

To store the CSP rules, we created a new table called trusted_sources in our PostgreSQL database. This table includes one column for each CSP directive, such as childSrc, connectSrc, fontSrc, and more. Each column is of type boolean, allowing us to enable or disable specific source types for each domain.

Fetching the data in the middleware

To get the CSP rules from the database, we created a GraphQL query in our API, which we called from the Next.js middleware. Since initializing Apollo Client in the middleware was not possible, we had to call GraphQL directly using the fetch method. We defined the following function, fetchTrustedSources, to retrieve the trusted sources from the backend.

export const fetchTrustedSources = async (): Promise<
FetchedTrustedSource[]
> => {
const trustedSourcesResult = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: "trustedSources",
variables: {
input: {
pagination: {
limit: 1000,
},
},
},
query: `
query trustedSources($input: TrustedSourcesInput) {
trustedSources(input: $input) {
data {
domain
childSrc
connectSrc
fontSrc
frameSrc
imgSrc
manifestSrc
mediaSrc
objectSrc
prefetchSrc
scriptSrc
scriptSrcElem
scriptSrcAttr
styleSrc
styleSrcElem
styleSrcAttr
workerSrc
}
}
}
`,
}),
})
return get(
await trustedSourcesResult.json(),
["data", "trustedSources", "data"],
[]
)
}

Parsing and caching the data

Building the CSP header from the rules provided by API was fairly straightforward. Once we had it working, we added a cache to avoid contacting the API on every request. This obviously improved the overall performance of the application. For improved flexibility, we used the environment variable REVALIDATE_INTERVAL to control the cache duration.

In the following piece of code, you can see the implementation of the CSP middleware. In the beginning, we are checking if the cache has expired. If it has, we fetch the data from the API and update the cache. Then we call the parseTrustedSources function, which is responsible for building the CSP header. If the cache has not expired, we simply use the cached data.

import * as Sentry from "@sentry/nextjs"
import { NextRequest, NextResponse } from "next/server"
import { parseTrustedSources } from "utils/cspParser"
import { fetchTrustedSources } from "./fetchTrustedSources"
import { TrustedSource } from "./types"
let trustedSourcesCache: {
updatedAt: number | null
data: TrustedSource[]
} = {
updatedAt: null,
data: [],
}
const REVALIDATE_INTERVAL = parseInt(process.env.CSP_REVALIDATE_INTERVAL) || 0
const getCSPHeader = async (nonce: string) => {
const timeNow = Date.now()
const hasCacheExpired =
!trustedSourcesCache.updatedAt ||
timeNow - trustedSourcesCache.updatedAt > REVALIDATE_INTERVAL
if (hasCacheExpired) {
try {
const trustedSources = await fetchTrustedSources()
trustedSourcesCache = {
updatedAt: timeNow,
data: trustedSources,
}
} catch (error) {
console.error(
"Failed to fetch trusted sources in middleware.",
error
)
Sentry.captureException(error)
}
}
return parseTrustedSources(trustedSourcesCache.data, nonce)
}
export const contentSecurityPolicyHeaderMiddleware = async (
request: NextRequest,
response: NextResponse,
generateNonce: boolean
) => {
let nonce = request.cookies.get("nonce")
if (!nonce || generateNonce) {
nonce = crypto.randomUUID().replaceAll("-", "")
const cspHeader = await getCSPHeader(nonce)
response.cookies.set("nonce", nonce)
response.headers.set("Content-Security-Policy", cspHeader)
}
return response
}

Calling the middleware

The final piece of the puzzle involved calling the middleware and modifying the response. The most crucial part of the code here is the filtering process when the response is modified. We don't want to generate a new nonce for CSP on every request; we only need it for the initial page load. Next.js often makes requests for prefetching pages or loading data for hydration, and we want to skip modifying the CSP for those requests.

export const middleware = (request: NextRequest) => {
const response = NextResponse.next()
const generateNonce =
request.headers.get("purpose") !== "prefetch" &&
request.headers.get("x-nextjs-data") !== "1"
return generateNonce
? contentSecurityPolicyHeaderMiddleware(
request,
response,
generateNonce
)
: response
}

What to keep in mind when implementing this solution

Managing Content Security Policy dynamically within your web application can be a powerful tool for improving security without the need for frequent redeployments. However, there are some security considerations to keep in mind when implementing this solution.

This feature can serve as a backdoor to your application so keeping the access limited is crucial. Make sure to use strong authentication and authorization mechanisms to protect your application from unauthorized access, like two-factor authentication and role-based access control. You should ensure that the database is properly secured and that the API for managing trusted domains is not exposed to the public.

You should also consider the security implications of allowing CSP rules to be modified by non-technical users. In our case, we decided to limit access to the dashboard for editing CSP to technical users only.

In summary, dynamic CSP management empowers web applications to adapt to evolving security needs, improving security while minimizing operational disruptions.

Share

Let's start a partnership together.

Let's talk

Our basecamp

700 N San Vicente Blvd, Los Angeles, CA 90069

Follow us


© 2023 Sudolabs

Privacy policy
Footer Logo

We use cookies to optimize your website experience. Do you consent to these cookies and processing of personal data ?