FlawOpen
● CWE-352 · OWASP A01:2021

Cross-Site Request Forgery (CSRF, CWE-352): How It Works and How to Prevent It

A page on another site can make a logged-in user's browser send a state-changing request to your app, and the browser attaches the session cookie automatically, so a route that only checks the cookie performs the action. Synchronizer tokens are the primary defense; SameSite cookies and Sec-Fetch-Site checks add defense in depth, with a Flask and Flask-WTF example.

Plain English Explainer (ELI5)

Your office has a mail room that carries out any signed request slip left in the 'out' tray, and it trusts slips because they sit in your tray, which only you can reach. A visitor asks you to drop a sealed envelope in your tray on the way out. Inside is a slip saying 'change the delivery address for all my parcels'. The mail room sees it in your tray, assumes you wrote it and carries it out. CSRF works the same way: the browser puts your session cookie on every request to your bank, including one that a different website started. The fix is a secret stamp that only pages from your own office can put on a slip, so the mail room rejects slips without it.

Core Concepts & Subsystem Terms

Ambient credentials
Browsers attach cookies and HTTP authentication to requests for a site no matter which page started them. A request from evil.example to app.example still carries the app.example session cookie unless the cookie's SameSite attribute blocks it.
SameSite cookie attribute
Strict withholds the cookie on every cross-site request, Lax sends it only on top-level GET navigations, and None always sends it (and requires Secure). Chromium treats cookies without the attribute as Lax; Firefox and Safari do not, and 'same-site' still includes sibling subdomains.
Synchronizer token
A random value tied to the session that the server puts in each form or page and requires on every unsafe request. Another site cannot read it, so it cannot forge a valid request. Flask-WTF's CSRFProtect issues it through csrf_token() and checks the csrf_token field or X-CSRFToken header.
Fetch Metadata (Sec-Fetch-Site)
When present, this browser-controlled request header reports whether the request is same-origin, same-site, cross-site, or user-initiated (none). It may be absent; keep CSRF tokens as the primary defense.
State change on GET
A route that changes data on GET can be triggered by an <img> tag or a link, and SameSite=Lax still sends the cookie on top-level GET navigations. Reading request.values accepts the query string as well as the form body.

Step-by-Step Attack Flow

Step 1

The victim is signed in

They have a session cookie for app.example, sent with SameSite=None or with no attribute in a browser that does not default to Lax.

Step 2

They open a page the attacker controls

It holds a hidden form that posts to https://app.example/account/email and submits itself on load. A form that posts the benign test value email=test@example.com illustrates the case.

Step 3

The browser sends the request with the cookie

The request carries the victim's session, and the server sees nothing that tells it the form lived on another site.

Step 4

The server changes the account

The email address becomes the attacker's, who then resets the password and takes over the account. In 2008 Zeller and Felten showed CSRF flaws of this kind at ING Direct, YouTube and The New York Times.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
# app.py: the session cookie is the only proof that the user meant it
import os

from flask import Flask, abort, request, session

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_SAMESITE="None",
)


# GET works too, and request.values also reads the query string
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
    return {"status": "ok"}
HARDENED SECURE PATCH
# app.py: token, SameSite cookie and Fetch Metadata together
import os

from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect

from .db import db

app = Flask(__name__)
app.config.update(
    SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
    SESSION_COOKIE_SECURE=True,
    SESSION_COOKIE_HTTPONLY=True,
    SESSION_COOKIE_SAMESITE="Lax",
)
# every POST needs csrf_token; forms render {{ csrf_token() }}, fetch() sends X-CSRFToken
csrf = CSRFProtect(app)


# Reject reported untrusted sites; CSRFProtect handles requests without this header.
@app.before_request
def reject_cross_site_writes():
    if request.method not in ("GET", "HEAD", "OPTIONS"):
        site = request.headers.get("Sec-Fetch-Site")
        # Missing Fetch Metadata is not proof of same-origin; CSRFProtect remains primary.
        if site is not None and site not in ("same-origin", "none"):
            abort(403)


# POST only, data from the form body
@app.post("/account/email")
def change_email():
    user_id = session.get("user_id")
    if user_id is None:
        abort(401)
    db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
    return {"status": "ok"}

Engineering & System Hardening Checklist

Sources

← Browse the full security directory All vulnerability guides →