3.6 KiB
name, description, tags, triggers
| name | description | tags | triggers | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| csp-form-action-debugging | Debug CSP form-action blocking issues — Chrome silently blocks forms when action URLs contain certain patterns in query params. |
|
|
CSP form-action Debugging
The Core Problem
Chrome's CSP form-action 'self' implementation has a known behavior: when a form's action URL contains // in the query string (e.g., ?redirect=https%3A//example.com/), the CSP evaluator may decode %3A → : internally, interpret :// as a scheme separator, and block the submission as cross-origin — even though the action URL itself is same-origin.
Symptom: Form POST returns net::ERR_ABORTED in DevTools network tab. Cookie may still be set (server responded), but browser doesn't follow the redirect.
Console error:
Sending form data to 'https://same-origin.example/api?redirect=https%3A//other.example/'
violates the following Content Security Policy directive: "form-action 'self'".
The request has been blocked.
Diagnosis Steps
-
Check CSP headers on the page:
curl -sI "https://page-url" | grep -i content-security-policyLook for
form-actiondirective. -
Check form action — is it a clean URL or does it carry redirect/return params in query string?
document.querySelector('form').action -
Test without query params — if form works without the redirect param in action URL, CSP is the culprit.
-
Verify with Playwright console listener:
page.on("console", lambda msg: print(f"[{msg.type}] {msg.text}")) # CSP violations appear as [error] messages
Fix Pattern
Move redirect/return_url from query string to hidden form field:
Before (broken):
<form method="POST" action="/api/login?redirect=https%3A//example.com/">
After (fixed):
<form method="POST" action="/api/login">
<input type="hidden" name="redirect" value="https://example.com/">
Backend change — read from Form body instead of Query params:
# Before
async def login(redirect: str | None = Query(default=None)):
# After
async def login(redirect: str | None = Form(default=None)):
Why This Happens
Chrome's CSP parser normalizes URLs before checking against form-action. The normalization decodes percent-encoded characters in the query string, turning %3A// into ://. The parser then treats the substring after :// as a different-origin host.
This does NOT affect:
- Relative paths in query params (
?redirect=/dashboard) - Same-origin absolute URLs in query params (
?redirect=https://same-origin.example/page) - Cross-origin URLs that don't contain
//(rare)
It DOES affect:
- Cross-origin URLs with
//in query params (the common case for redirect parameters)
Pitfalls
- Don't just remove
form-actionfrom CSP — it's a valuable XSS mitigation. The hidden field fix is better. redirect: 'manual'in fetch shows the response asopaqueredirectwith status 0 — this confirms the server IS responding correctly; the block is client-side.- Rate limiting can confuse diagnosis — if you're testing login repeatedly, 429 responses may appear alongside CSP errors. Wait 60s between tests or use fresh browser contexts.
- This affects ALL forms, not just login. Any form that passes URLs in query parameters (e.g.,
?return_to=,?next=,?callback=) is vulnerable.