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.exampleparaapp.exampleainda leva o cookie de sessão deapp.example, a menos que o atributoSameSitedo cookie a bloqueie. - Atributo SameSite do cookie
Strictretém o cookie em toda requisição entre sites,Laxsó o envia em navegações GET de nível superior eNonesempre o envia (e exigeSecure). O Chromium trata cookies sem o atributo comoLax; 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
CSRFProtectdo Flask-WTF o emite viacsrf_token()e confere o campocsrf_tokenou o cabeçalhoX-CSRFToken. - Fetch Metadata (Sec-Fetch-Site)
- Navegadores modernos enviam
Sec-Fetch-Site: cross-site,same-site,same-originounoneem toda requisição, e as páginas não conseguem forjá-lo. Rejeitar métodos inseguros que não sejamsame-originbloqueia 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, eSameSite=Laxainda envia o cookie em navegações GET de nível superior. Lerrequest.valuesaceita a query string além do corpo do formulário.
Fluxo de Ataque Passo a Paso
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.
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.
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.
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
# 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"}
# 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
- Altere estado só em POST, PUT, PATCH ou DELETE e leia os dados do formulário no corpo (
request.form), nunca emrequest.valuesou na query string. - Ative o middleware de CSRF do framework para o app inteiro (
CSRFProtect,CsrfViewMiddlewaredo Django,csrf()do Spring Security) e inclua o token em todo formulário e requisição AJAX. - Defina os cookies de sessão como
Secure,HttpOnlyeSameSite=LaxouStrict; useSameSite=Nonesó em cookies que precisam funcionar dentro de iframes de outros sites. - Rejeite requisições inseguras cujo cabeçalho
Sec-Fetch-Sitesejacross-siteousame-site. - Adicione um teste no CI que envie a cada rota que altera estado uma requisição sem o token e espere 400 ou 403.