first commit
This commit is contained in:
89
software-development/csp-form-action-debugging/SKILL.md
Normal file
89
software-development/csp-form-action-debugging/SKILL.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: csp-form-action-debugging
|
||||
description: "Debug CSP form-action blocking issues — Chrome silently blocks forms when action URLs contain certain patterns in query params."
|
||||
tags: [csp, security, debugging, form-action, chrome]
|
||||
triggers:
|
||||
- "form submission fails silently"
|
||||
- "CSP form-action violation"
|
||||
- "login redirect not working"
|
||||
- "form POST blocked by browser"
|
||||
- "ERR_ABORTED on form submit"
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
1. **Check CSP headers** on the page:
|
||||
```
|
||||
curl -sI "https://page-url" | grep -i content-security-policy
|
||||
```
|
||||
Look for `form-action` directive.
|
||||
|
||||
2. **Check form action** — is it a clean URL or does it carry redirect/return params in query string?
|
||||
```js
|
||||
document.querySelector('form').action
|
||||
```
|
||||
|
||||
3. **Test without query params** — if form works without the redirect param in action URL, CSP is the culprit.
|
||||
|
||||
4. **Verify with Playwright console listener:**
|
||||
```python
|
||||
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):
|
||||
```html
|
||||
<form method="POST" action="/api/login?redirect=https%3A//example.com/">
|
||||
```
|
||||
|
||||
After (fixed):
|
||||
```html
|
||||
<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:
|
||||
```python
|
||||
# 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-action` from CSP** — it's a valuable XSS mitigation. The hidden field fix is better.
|
||||
- **`redirect: 'manual'` in fetch** shows the response as `opaqueredirect` with 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.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Case Study: ephron.ren Login Redirect Failure (2026-05-05)
|
||||
|
||||
## Context
|
||||
- Site: ephron.ren (multi-service: Home/Auth/Blog/Canvas/Prompt)
|
||||
- Auth service at auth.ephron.ren, CSP includes `form-action 'self'`
|
||||
- Login page at `/login` accepts `?redirect=<url>` query parameter
|
||||
|
||||
## Symptom
|
||||
- User on www.ephron.ren clicks "未登录" → jumps to auth.ephron.ren/login?redirect=https%3A//www.ephron.ren/
|
||||
- Enters credentials, clicks login
|
||||
- Browser stays on login page, no redirect happens
|
||||
- But: auth cookie IS set (server responded correctly)
|
||||
|
||||
## Root Cause
|
||||
The Jinja2 template rendered the form action as:
|
||||
```html
|
||||
<form action="/api/login?redirect=https%3A//www.ephron.ren/">
|
||||
```
|
||||
Chrome's CSP `form-action 'self'` evaluator decoded `%3A` → `:` in the query string, saw `://`, interpreted it as cross-origin, and blocked the form submission.
|
||||
|
||||
## Playwright Evidence
|
||||
```
|
||||
[error] Sending form data to 'https://auth.ephron.ren/api/login?redirect=https%3A//www.ephron.ren/'
|
||||
violates the following Content Security Policy directive: "form-action 'self'".
|
||||
The request has been blocked.
|
||||
```
|
||||
|
||||
Network trace showed `net::ERR_ABORTED` with no blocked reason.
|
||||
|
||||
## Fix Applied
|
||||
1. Template: `<form action="/api/login">` + `<input type="hidden" name="redirect" value="{{ redirect }}">`
|
||||
2. Backend: Changed `redirect: str | None = Query(default=None)` → `Form(default=None)`
|
||||
|
||||
## Key Learning
|
||||
The Jinja2 `urlencode` filter preserves `/` as safe characters (`%3A//` not `%3A%2F%2F`), but even fully encoding (`%3A%2F%2F`) doesn't help — Chrome normalizes URLs before CSP evaluation.
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Diagnose CSP form-action blocking on a target page.
|
||||
|
||||
Usage:
|
||||
python diagnose_csp_form_action.py <login_url> <username> <password>
|
||||
|
||||
Produces:
|
||||
- CSP header analysis
|
||||
- Form action inspection
|
||||
- Console message capture (CSP violations)
|
||||
- Network request trace (ERR_ABORTED detection)
|
||||
- Cookie state after submission
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
|
||||
async def diagnose(login_url: str, username: str, password: str):
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
context = await browser.new_context()
|
||||
page = await context.new_page()
|
||||
|
||||
console_msgs = []
|
||||
page.on("console", lambda msg: console_msgs.append(f"[{msg.type}] {msg.text}"))
|
||||
|
||||
# CDP for detailed network tracing
|
||||
cdp = await context.new_cdp_session(page)
|
||||
await cdp.send("Network.enable")
|
||||
|
||||
network_events = []
|
||||
|
||||
def on_resp(params):
|
||||
resp = params.get("response", {})
|
||||
network_events.append({
|
||||
"type": "response",
|
||||
"status": resp.get("status"),
|
||||
"url": resp.get("url", "")[:120],
|
||||
"location": resp.get("headers", {}).get("location", ""),
|
||||
})
|
||||
|
||||
def on_fail(params):
|
||||
network_events.append({
|
||||
"type": "fail",
|
||||
"error": params.get("errorText", ""),
|
||||
"reason": params.get("blockedReason", ""),
|
||||
})
|
||||
|
||||
cdp.on("Network.responseReceived", on_resp)
|
||||
cdp.on("Network.loadingFailed", on_fail)
|
||||
|
||||
# Step 1: Load page
|
||||
print(f"Loading: {login_url}")
|
||||
await page.goto(login_url)
|
||||
|
||||
# Step 2: Check CSP
|
||||
csp = await page.evaluate("""
|
||||
async () => {
|
||||
const r = await fetch(location.href, {method: 'HEAD'});
|
||||
return r.headers.get('content-security-policy') || 'none';
|
||||
}
|
||||
""")
|
||||
has_form_action = "form-action" in csp
|
||||
print(f"CSP form-action directive: {'YES' if has_form_action else 'none'}")
|
||||
if has_form_action:
|
||||
# Extract just the form-action part
|
||||
for part in csp.split(";"):
|
||||
if "form-action" in part:
|
||||
print(f" → {part.strip()}")
|
||||
|
||||
# Step 3: Inspect form
|
||||
form_info = await page.evaluate("""
|
||||
() => {
|
||||
const form = document.querySelector('form');
|
||||
if (!form) return null;
|
||||
return {
|
||||
action: form.action,
|
||||
method: form.method,
|
||||
hasQueryInAction: form.action.includes('?'),
|
||||
hiddenFields: Array.from(form.querySelectorAll('input[type=hidden]'))
|
||||
.map(i => ({name: i.name, value: i.value.substring(0, 80)}))
|
||||
};
|
||||
}
|
||||
""")
|
||||
if form_info:
|
||||
print(f"\nForm action: {form_info['action']}")
|
||||
print(f"Has query in action: {form_info['hasQueryInAction']}")
|
||||
print(f"Hidden fields: {form_info['hiddenFields']}")
|
||||
else:
|
||||
print("No form found on page!")
|
||||
return
|
||||
|
||||
# Step 4: Submit form
|
||||
await page.fill('input[name="username"], #username', username)
|
||||
await page.fill('input[name="password"], #password', password)
|
||||
|
||||
print("\nSubmitting form...")
|
||||
try:
|
||||
async with page.expect_navigation(timeout=8000):
|
||||
await page.click('button[type="submit"], input[type="submit"]')
|
||||
print(f"Navigation OK → {page.url}")
|
||||
except Exception as e:
|
||||
print(f"Navigation FAILED: {e}")
|
||||
print(f"Stayed on: {page.url}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Step 5: Report
|
||||
cookies = await context.cookies()
|
||||
auth_cookies = [c for c in cookies if 'auth' in c['name'].lower()]
|
||||
print(f"\nAuth cookies: {len(auth_cookies)}")
|
||||
for c in auth_cookies:
|
||||
print(f" {c['name']} domain={c['domain']}")
|
||||
|
||||
csp_errors = [m for m in console_msgs if "form-action" in m or "violates" in m]
|
||||
print(f"\nCSP violations: {len(csp_errors)}")
|
||||
for e in csp_errors:
|
||||
print(f" {e}")
|
||||
|
||||
failures = [e for e in network_events if e["type"] == "fail"]
|
||||
print(f"\nNetwork failures: {len(failures)}")
|
||||
for f in failures:
|
||||
print(f" {f['error']} (reason: {f['reason']})")
|
||||
|
||||
verdict = "BLOCKED" if csp_errors else ("OK" if "login-success" in page.url or "www." in page.url else "UNKNOWN")
|
||||
print(f"\n{'='*40}")
|
||||
print(f"VERDICT: {verdict}")
|
||||
|
||||
await browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 4:
|
||||
print(f"Usage: {sys.argv[0]} <login_url> <username> <password>")
|
||||
sys.exit(1)
|
||||
asyncio.run(diagnose(sys.argv[1], sys.argv[2], sys.argv[3]))
|
||||
Reference in New Issue
Block a user