● CWE-352 · OWASP A01:2021

Cross-Site Request Forgery (CSRF, CWE-352): como funciona e como evitar

Uma página de outro site pode fazer o navegador de um usuário logado enviar ao seu app uma requisição que altera dados, e o navegador anexa o cookie de sessão automaticamente, então uma rota que só confere o cookie executa a ação. Como tokens sincronizadores, cookies SameSite e verificações de Sec-Fetch-Site impedem isso, com um exemplo em Flask e Flask-WTF.

Explicação em Linguagem Simples (ELI5)

Seu escritório tem uma sala de correspondência que executa qualquer pedido assinado deixado na sua bandeja de saída, e confia nos pedidos porque eles estão na sua bandeja, que só você alcança. Um visitante pede que você deixe um envelope fechado na bandeja ao sair. Dentro há um pedido dizendo 'mude o endereço de entrega de todos os meus pacotes'. A sala de correspondência o vê na sua bandeja, supõe que foi você quem escreveu e o executa. O CSRF funciona do mesmo jeito: o navegador põe seu cookie de sessão em toda requisição ao seu banco, inclusive numa iniciada por outro site. A correção é um carimbo secreto que só as páginas do seu próprio escritório conseguem pôr no pedido, para que a sala recuse pedidos sem ele.

Conceitos Centrais e Termos

Credenciais ambientes
Os navegadores anexam cookies e autenticação HTTP às requisições para um site, não importa qual página as iniciou. Uma requisição de evil.example para app.example ainda leva o cookie de sessão de app.example, a menos que o atributo SameSite do cookie a bloqueie.
Atributo SameSite do cookie
Strict retém o cookie em toda requisição entre sites, Lax só o envia em navegações GET de nível superior e None sempre o envia (e exige Secure). O Chromium trata cookies sem o atributo como Lax; Firefox e Safari não, e 'mesmo site' ainda inclui subdomínios irmãos.
Token sincronizador
Um valor aleatório ligado à sessão que o servidor coloca em cada formulário ou página e exige em toda requisição insegura. Outro site não consegue lê-lo, então não consegue forjar uma requisição válida. O CSRFProtect do Flask-WTF o emite via csrf_token() e confere o campo csrf_token ou o cabeçalho X-CSRFToken.
Fetch Metadata (Sec-Fetch-Site)
Navegadores modernos enviam Sec-Fetch-Site: cross-site, same-site, same-origin ou none em toda requisição, e as páginas não conseguem forjá-lo. Rejeitar métodos inseguros que não sejam same-origin bloqueia o CSRF numa única verificação no servidor.
Mudança de estado em GET
Uma rota que altera dados em GET pode ser disparada por uma tag <img> ou um link, e SameSite=Lax ainda envia o cookie em navegações GET de nível superior. Ler request.values aceita a query string além do corpo do formulário.

Fluxo de Ataque Passo a Paso

Passo 1

A vítima está logada

Ela tem um cookie de sessão de app.example, enviado com SameSite=None ou sem o atributo num navegador que não adota Lax por padrão.

Passo 2

Ela abre uma página controlada pelo atacante

A página tem um formulário oculto que envia para https://app.example/account/email e se submete ao carregar. Um formulário que envia o valor de teste inofensivo email=test@example.com ilustra o caso.

Passo 3

O navegador envia a requisição com o cookie

A requisição leva a sessão da vítima, e o servidor não vê nada que indique que o formulário estava em outro site.

Passo 4

O servidor altera a conta

O e-mail passa a ser o do atacante, que então redefine a senha e toma a conta. Em 2008 Zeller e Felten mostraram falhas de CSRF desse tipo no ING Direct, no YouTube e no The New York Times.

Código-Fonte: Vulnerável vs. Seguro

IMPLEMENTAÇÃO VULNERÁVEL
# app.py: o cookie de sessão é a única prova de que o usuário quis isso
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 também funciona, e request.values lê também a 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"}
PATCH SEGURO E ROBUSTO
# app.py: token, cookie SameSite e Fetch Metadata juntos
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",
)
# todo POST exige csrf_token; formulários renderizam {{ csrf_token() }}, fetch() envia X-CSRFToken
csrf = CSRFProtect(app)


# escritas iniciadas por outro site são recusadas antes de a rota rodar
@app.before_request
def reject_cross_site_writes():
    if request.method not in ("GET", "HEAD", "OPTIONS"):
        if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
            abort(403)


# só POST, dados vindos do corpo do formulário
@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"}

Lista de Verificação de Segurança para Engenharia

Fontes

← Ver o diretório completo de segurança Todos os guias de vulnerabilidades →