iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

JSON / API CSRF

CSRF on JSON endpoints used to be considered impossible because application/json requires a preflight. Modern attackers use fetch with credentials, custom content types, or browser quirks. Defend at every layer.

Lock down content-type, SameSite, tokens

EXAMPLE
// 1) The classical assumption (now incomplete)
// 'JSON APIs are CSRF-safe because forms can't send application/json without preflight.'
// True for the simplest cases — but only if the server REJECTS other content types.

// === Attack vectors that bypass the assumption ===

// 1a) Server accepts text/plain or no content-type
// Form-based: <form enctype='text/plain' action='https://victim/api/transfer' method='POST'>
//   <input name='{"amount":1000,"to":"evil"}'>
// </form>
// Body posted: {"amount":1000,"to":"evil"}=
// If the server only checks the JSON SHAPE (not the content-type header), this works.

// 1b) Lax CORS + credentials
// fetch('https://victim/api/transfer', {
//     method: 'POST',
//     credentials: 'include',                        // browser sends cookie
//     headers: { 'content-type': 'text/plain' },     // SIMPLE — no preflight
//     body: JSON.stringify({ amount: 1000, to: 'evil' }),
// });
// Bypasses preflight; works if server doesn't enforce content-type.

// 1c) Subdomain takeover or trusted origin in CORS allowlist
// If api.example.com trusts *.example.com and an attacker takes over old.example.com,
// they have CORS + credentials — instant CSRF.

// === Defences ===

// 2) Require application/json content type on writes — and CHECK IT
import express from 'express';
const app = express();
app.use(express.json());

app.use((req, res, next) => {
    if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
        const ct = req.headers['content-type'] ?? '';
        if (!ct.includes('application/json')) {
            return res.status(415).json({ error: 'Unsupported Media Type' });
        }
    }
    next();
});

// 3) Require a custom header (forces preflight)
app.use((req, res, next) => {
    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
        if (!req.headers['x-requested-with']) {
            return res.status(403).json({ error: 'csrf' });
        }
    }
    next();
});
// Client: fetch('/api/...', { headers: { 'X-Requested-With': 'XMLHttpRequest', 'content-type': 'application/json' } })

// 4) Strict CORS
import cors from 'cors';
const ALLOWED = new Set(['https://app.example.com']);
app.use(cors({
    origin: (origin, cb) => cb(null, ALLOWED.has(origin ?? '')),
    credentials:    true,
    allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'X-CSRF-Token'],
}));
// Never set Access-Control-Allow-Origin: *  AND  Allow-Credentials: true.

// 5) SameSite=Lax on the session cookie (default since Chrome 80)
app.use(session({
    cookie: { httpOnly: true, secure: true, sameSite: 'lax' },
}));

// 6) CSRF token — defence in depth
// Generate per session; require in a header on mutating requests.
app.get('/csrf', (req, res) => {
    req.session.csrf ??= crypto.randomBytes(32).toString('base64url');
    res.json({ token: req.session.csrf });
});

app.use((req, res, next) => {
    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
        const sent = req.headers['x-csrf-token'];
        if (!sent || sent !== req.session.csrf) {
            return res.status(403).json({ error: 'csrf' });
        }
    }
    next();
});

// Client (TanStack Query example)
const { data: { token } } = useQuery({
    queryKey: ['csrf'],
    queryFn:  () => fetch('/csrf').then(r => r.json()),
});

await fetch('/api/post', {
    method:  'POST',
    credentials: 'include',
    headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': token,
    },
    body:    JSON.stringify(data),
});

// 7) Re-authenticate sensitive actions
// Password change / email change / payouts: require password re-entry or step-up MFA,
// even with valid session — limits damage from any residual CSRF window.

// 8) Origin / Referer checks
// As a belt-and-braces backup:
app.use((req, res, next) => {
    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
        const origin = req.headers.origin || req.headers.referer;
        if (!origin || !ALLOWED.has(new URL(origin).origin)) {
            return res.status(403).end();
        }
    }
    next();
});

// 9) Token in cookie + header (double-submit) — stateless variant
// 1. Server sets a CSRF cookie (not HttpOnly so JS can read it)
// 2. Client copies cookie value into X-CSRF-Token on every write
// 3. Server compares cookie vs header
// Pros: works with serverless / stateless backends
// Cons: vulnerable to subdomain attacks unless cookie is __Host-prefixed

// 10) Anti-patterns
//   ❌ Trusting Referer alone (browsers can be configured to strip it)
//   ❌ Same token forever — rotate on auth state change
//   ❌ Reading the token from a JS variable embedded in HTML by your own templating without CSP
//   ❌ GET endpoints that mutate state — STILL vulnerable even with all the above

// 11) Real attacks observed in the wild
// - Subdomain takeover → CORS abused → drained crypto exchange accounts
// - Misconfigured framework default that accepted text/plain JSON → wire transfer fraud
// - Outdated CSRF lib that compared base64 token strings non-constant-time → token extraction via timing

// 12) Modern defence stack — assume you'll get one wrong, layer the rest
//   SameSite=Lax cookies                  (browser default defence)
//   Strict CORS allowlist                  (origin defence)
//   Content-type enforced application/json (force preflight)
//   X-Requested-With required              (force preflight again)
//   Per-session CSRF token in header       (cryptographic check)
//   Re-auth for sensitive actions          (limits blast radius)
//   Origin / Referer check                 (belt + braces)
//   No GET-with-side-effects               (architectural defence)

Why it matters

JSON-only is necessary, not sufficient. Enforce content-type, require a custom header, lock CORS to your domain, set SameSite=Lax, and add a CSRF token — each layer covers a gap the others miss.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// JSON endpoints aren't auto-safe.
// Attacker page can POST a form with Content-Type: text/plain that the
// server still parses if it allows JSON via that content type.
// Defence: require a custom header (X-CSRF-Token / X-Requested-With) — preflighted by CORS.
Try it Yourself »

Discussion

Loading…