bb-csrf
Hunt modern CSRF — content-type-blocklist bypasses (typeless Blob POSTs, Chromium safelist additions like message/ad-auction-trusted-signals-request, missing-Content-Type-means-JSON parsers), GraphQL
SKILL.mdskills/bb-csrf/SKILL.md
GitHubModern CSRF Hunter
CSRF was declared dead and isn't. The modern defense is "the request isn't a simple request, so the browser sends a preflight" — that defense breaks at three layers: the server's parser, the browser's safelist, and GraphQL's transport quirks. Techniques below from Voorivex Team research (2026) and paid/CTF-grade cases.
Break 1 — The server parses more than the safelist allows
Typeless Blob POST (works everywhere, paid on FastAPI): a Blob with no type sends NO Content-Type header at all — no preflight, cookies attached:
fetch(url, { method: "POST", credentials: "include",
body: new Blob([JSON.stringify({email: "attacker@x.tld", action: "update"})]) });
FastAPI's (since-patched) if not content_type_value: json_body = await request.json() parsed the typeless body as JSON. Audit targets for "missing Content-Type falls back to JSON" parsing.
Headerless primitives: anything that posts without settable headers — <form> (urlencoded/multipart/text-plain), navigation requests, no-cors fetch with Blob — combined with lenient parsing is the attack surface.
Break 2 — The browser safelist is bigger than the server thinks
Fetch spec simple-request Content-Types: application/x-www-form-urlencoded, multipart/form-data, text/plain. Servers blocklist exactly these three — but Chromium's IsCorsSafelistedLowerCaseContentType briefly had a FOURTH, default-on entry: message/ad-auction-trusted-signals-request (Protected Audience API):
fetch(url, { method: "POST", credentials: "include",
headers: { "Content-Type": "message/ad-auction-trusted-signals-request" },
body: JSON.stringify({...}) });
No preflight on unpatched Chrome/Edge/Brave — sailed past every server blocklist including Apollo Server's NON_PREFLIGHTED_CONTENT_TYPES. Lesson: diff browser source against the spec — Chromium-only safelist additions are default-on bypasses. Re-test "blocked" content-type CSRF on browser updates.
Break 3 — GraphQL transports
- POST blocked, GET isn't: Apollo Server's
preventCsrfonly fires on non-simple content types; mutations via GET withquery/variables/operationNameas URL params skip it entirely (body-parser only guards POST). - APQ defeats URL length limits: prime persisted queries server-to-server (POST full query +
extensions.persistedQuery.sha256Hash), then the victim's browser needs only/graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"<hash>"}}— tiny URL, arbitrary query content. - Timing XS-Leak turns unreadable responses into an oracle:
performance.getEntriesByName(url).durationmeasures download time. Amplify hit-vs-miss with aliases — thousands ofh123:text@include(if: true)fields balloon a matching response to tens of KB. SLOW (>10ms, 5 trials) = character match → append to the leaked prefix, re-prime, walk secrets character by character. Works because GET+APQ is a simple request the victim's browser performs cross-site with cookies.
Classic checks (60-second pass, still pays)
- State-changing GET (
GET /api/user/delete?...) — tokens "verified" only on POST. - Token present but unvalidated / not bound to session / accepted from any account.
Referercheck: missing-Referer accepted?<meta name="referrer" content="no-referrer">on your attacker page makes it absent.SameSite=Laxblocks cross-site POST but NOT top-level navigation GET — any state change reachable by GET link-click is still exploitable; also check cookie exceptions (Lax+POST 2-minute window after link click).- Consent screens and OAuth DCR flows (registration endpoints have no CSRF token — see bb-oauth MCP section).
Workflow
- Inventory state-changing endpoints (authenticated browser pass; js-recon for the full API surface).
- For each: identify the defense (token? content-type requirement? SameSite? Referer?).
- Try the matching break: typeless Blob → fourth-safelist content type → GET transport → classic gaps.
- PoC from a separate origin (local file or your page): the action must complete with the victim's cookies and NO user interaction beyond visiting.
- Impact: state what changed (email/password change → ATO-grade; settings change → justify).
Verdict + evidence
- Deliverable: standalone HTML PoC that performs the action on a logged-in victim, plus before/after evidence on YOUR OWN test account.
- A CSRF that only changes cosmetics = Low; email/password/2FA/MFA-method change = High (chain to ATO via bb-chains).
- Never fire PoCs at other users' accounts — self-account before/after is the accepted proof.