Next.js: Crafting a Strict CSP

0xdbe · March 4, 2024

Next.js lacks many built-in security measures. In fact, it doesn’t offer predefined configurations for your Content Security Policy (CSP). Consequently, setting up CSP becomes your responsibility. Let’s explore how we can implement a CSP.

Requirements

To ensure a Content Security Policy that aligns with the current standards, we need to meet the following requirements:

  • must include a nonce because Next.js often utilizes inline scripts, such as those for AppRouter.
  • must be delivered through an HTTP Header. Avoid embedding a CSP in a meta tag with http-equiv as it lacks support for crucial features, such as violation reports and frame-ancestors directive.

Middleware

In Next.js, middleware allows you to execute a function before a request is completed. In our case, a middleware will be used to generate a random nonce and then create HTTP headers before the page renders.

Here is the middleware function, which can be adjusted if a middleware chain is used:

// file: middleware.ts
export function middleware(request: NextRequest) {

  // step 1
  const response = initResponse()

  // step 2
  const reportUri = getReportUri()
  response.headers.set(
    'Report-To', getReportToHeaderValue(reportUri)
  )

  // step 3
  const nonce = getNonce()

  // step 4
  response.headers.set(
    'Content-Security-Policy',
    getContentSecurityPolicyHeaderValue(nonce, reportUri)
  )

  return response
}

Helper functions

Step 1: Initialize Response

The first step involves initializing the response using the following function:

function initResponse(): NextResponse {
  return NextResponse.next()
}

Using NextResponse.next() is suitable, in this case, to create an empty response. Although if you’re using a chain middleware where the response is already initialized, you might skip this step.

Step 2: Define CSP Violation Reporting

The second step is defining where CSP violation reports will be sent.

Here’s the function:

function getReportToHeaderValue(reportUri: string): string {
  const reportTo = {
    group: 'csp',
    max_age: 10886400, //1 day
    endpoints: [{ url: reportUri }],
  }
  return JSON.stringify(reportTo)
}

Certain providers like Sentry or Datadog offer the capability to collect information on CSP (Content-Security-Policy) violations. Remember, if you’re testing violation reports, they won’t be sent if you’re using localhost hostname or an insecure protocol like http.

Step 3: Generate Nonce

The third step involves generating a random nonce using randomUUID():

function getNonce(): string {
  return Buffer.from(crypto.randomUUID()).toString('base64')
}

Step 4: Generate CSP Content

The final step is to generate the content of the CSP, which is perhaps the most intricate task.

Here’s a function to define the CSP:

function getContentSecurityPolicyHeaderValue(
    nonce: string,
    reportUri: string,
  ): string {

    // Default CSP for Next.js
    const contentSecurityPolicyDirective = {
      'base-uri': [`'self'`],
      'default-src': [`'none'`],
      'frame-ancestors': [`'none'`],
      'font-src': [`'self'`],
      'form-action': [`'self'`],
      'frame-src': [`'self'`],
      'connect-src': [`'self'`],
      'img-src': [`'self'`],
      'manifest-src': [`'self'`],
      'object-src': [`'none'`],
      'report-uri': [reportUri], // for old browsers like Firefox
      'report-to': ['csp'], // for modern browsers like Chrome
      'script-src': [
        `'nonce-${nonce}'`,
        `'strict-dynamic'`, // force hashes and nonces over domain host lists
      ],
      'style-src': [`'self'`],
    }

    if (process.env.NODE_ENV === 'development') {
      // Webpack use eval() in development mode for automatic JS reloading
      contentSecurityPolicyDirective['script-src'].push(`'unsafe-eval'`)
    }

    if (process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview') {
      contentSecurityPolicyDirective['connect-src'].push('https://vercel.live')
      contentSecurityPolicyDirective['connect-src'].push('wss://*.pusher.com')
      contentSecurityPolicyDirective['img-src'].push('https://vercel.com')
      contentSecurityPolicyDirective['font-src'].push('https://vercel.live')
      contentSecurityPolicyDirective['frame-src'].push('https://vercel.live')
      contentSecurityPolicyDirective['style-src'].push('https://vercel.live')
    }

    return Object.entries(contentSecurityPolicyDirective)
      .map(([key, value]) => `${key} ${value.join(' ')}`)
      .join('; ')

}

If you need to complete the default CSP, use the push method to add additional sources.

Page Render

Once we have middleware that creates a CSP with a nonce, we need to include this nonce on pages. Every time a page is viewed, a fresh nonce should be generated. This means you must use dynamic rendering to add nonces:

// file: app/layout.tsx
export const dynamic = 'force-dynamic'

With this option, Next.js will be able to insert a random number on the rendered page.

To achieve this, Next.js utilizes getScriptNonceFromHeader to extract the nonce from the CSP HTTP header. Then, AppRender includes the nonce in all script elements.

Warning: Keep in mind that dynamic rendering can increase hosting costs. For example, on Vercel, page rendering is done as a serverless function.

Conclusion

Next.js is not designed to use a strict Content-Security-Policy, which may discourage many software engineers. I hope this article can help some of them who might be tempted to use the unsafe-inline keyword.

If you haven’t yet selected a framework for your next secure app, consider avoiding Next.js and opting for a better-suited one, although I haven’t identified it yet.

Twitter, Facebook