guest@websec: ~/websec/07-csrf — zsh — 100×40 by SaKKo ENTH code:

guest@websec ~ % man 07-csrf

07 · Cross-Site Request Forgery

CSRF tricks your logged-in browser into sending a request you never meant to send. Your cookies go along for the ride, so the app thinks it was you.

How it works

Browsers send your cookies with every request to a site, no matter which page started it. That's what keeps you logged in, and what CSRF abuses.

The attacker can't see the response. They just make things happen: change an email, move money, add an admin.

Try it

sim: bank.test vs evil.testset defenses, launch attacks

Alice is logged in to the bank. Set up the bank's defenses, then attack from the evil page.

bank.test configuration




Alice's balance: $5,000

evil.test page

7.1 Countermeasures

Use GET and POST correctly

Rule one: GET only reads. Anything that changes data uses POST, PATCH or DELETE. Most CSRF defenses skip GET on purpose, so a GET that deletes stuff is wide open.

Require a secret token on state-changing requests

The classic fix: a secret token in every form. The evil site can make your browser send cookies, but it can't read our page, so it never knows the token.

On by default. Rails forms include the token, and csrf_meta_tags covers Turbo and fetch. Watch out: API-only controllers have no CSRF protection, and any skip_forgery_protection deserves a second look.
class ApplicationController < ActionController::Base
  # on by default since Rails 5.2; being explicit makes failures loud
  protect_from_forgery with: :exception

  rescue_from ActionController::InvalidAuthenticityToken do
    terminate_session if respond_to?(:terminate_session, true)   # generator helper
    cookies.delete(:remember_token)
    redirect_to new_session_path, alert: "Please sign in again."
  end
end

<%# app/views/layouts/application.html.erb %>
<%= csrf_meta_tags %>                         <%# token for Turbo / JS %>
<%= form_with model: @transfer do |f| %>      <%# hidden authenticity_token %>

// your own fetch() calls
fetch("/transfers", { method: "POST", headers: {
  "X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content }, body })
// npm install csrf-sync   (synchronizer token pattern)
const { csrfSynchronisedProtection, generateToken } = require("csrf-sync")({
  getTokenFromRequest: req => req.body._csrf || req.get("x-csrf-token"),
});
app.use(session(/* … */));
app.use(csrfSynchronisedProtection);   // checks POST/PUT/PATCH/DELETE

app.get("/transfer", (req, res) =>
  res.render("transfer", { csrfToken: generateToken(req) }));

<!-- template -->
<meta name="csrf-token" content="<%= csrfToken %>">
<form method="post" action="/transfer">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>"> …

// fetch from your own JS
fetch("/transfer", { method: "POST", headers: {
  "X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content }, body });
# Django: CsrfViewMiddleware is enabled by default
#   <form method="post">{% csrf_token %} … </form>
#   JS: send the csrftoken cookie value in the X-CSRFToken header

# Flask: pip install flask-wtf
from flask_wtf.csrf import CSRFProtect
csrf = CSRFProtect(app)          # checks POST/PUT/PATCH/DELETE

#   <meta name="csrf-token" content="{{ csrf_token() }}">
#   <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
#   fetch(url, {method:"POST", headers:{"X-CSRFToken": token}})

Layer on SameSite cookies and origin checks

Two cheap extra layers:

  • SameSite=Lax cookies aren't sent on cross-site POSTs or images. They still go with normal link clicks, so it won't save a dangerous GET.
  • Origin checks: reject POSTs that say they came from another site.
Rails sets SameSite=Lax on cookies by default (since 6.1). Check older apps haven't turned it off.

Don't let other sites load your data as a script

If a URL returns private data as JavaScript, another site can load it with a <script> tag and read it. Return JSON instead, never JSONP.

Rails already blocks this for .js responses.

Clear persistent cookies when a forgery is detected

When a forged request is caught, also clear any "remember me" cookie, or the user just gets logged straight back in.

By default Rails raises and clears nothing. Add a rescue_from ActionController::InvalidAuthenticityToken that ends the session and deletes those cookies.
XSS beats all of this. Injected script runs on our own site and can read the token. Stop XSS first (section 04).