Segurança web e de APIs
OWASP Top 10 explicado para desenvolvedores
O que é o OWASP Top 10, o que cada categoria de 2025 significa, como se defender e como mapear desafios do Juice Shop.
CSRF usa os cookies do seu próprio navegador contra você. Veja as defesas da mais forte para a mais fraca e teste o SameSite com um laboratório Flask.
Cross-site request forgery (CSRF, falsificação de requisição entre sites) permite que uma página de um site faça o seu navegador enviar uma requisição que altera dados a outro site em que você está logado. O navegador anexa o cookie de sessão sozinho, então o outro site trata a requisição como se fosse sua. As defesas são um segredo por sessão que o atacante não consegue ler, o atributo SameSite do cookie, a checagem do Origin e a regra de que requisições GET nunca alteram estado. Este artigo cobre cada uma delas e depois roda um pequeno laboratório em Flask para você ver o cookie em movimento.
Uma sessão web normalmente vive em um cookie. Depois que você entra em bank.example, o navegador guarda um cookie para esse host e o envia em toda requisição para ele. O servidor lê o cookie, encontra a sua sessão e executa a ação.
O navegador não verifica de onde veio a requisição. Se uma página em evil.example tem um formulário que envia um POST para https://bank.example/transfer, o navegador manda esse POST com o cookie de bank.example anexado. O banco vê uma sessão válida e faz a transferência. O atacante nunca vê o seu cookie e nunca lê a resposta. O ataque só precisa que o servidor execute a ação.
A CWE-352 descreve a fraqueza assim: a aplicação executa uma ação que altera estado para um usuário sem verificar se ele quis fazer aquela requisição. A correção é permitir que o servidor distinga uma requisição feita pelo usuário de uma forjada por uma página de terceiros.
Qualquer aplicação que guarda sessão em cookie e altera dados numa requisição está exposta. As ações mais importantes são as que mudam credenciais ou configurações: trocar a senha, trocar o e-mail, criar uma chave de API, cadastrar um meio de pagamento, criar uma regra de encaminhamento. Trocar o e-mail sem autorização costuma ser o primeiro passo de uma tomada de conta, porque o atacante pode redefinir a senha pelo endereço novo.
Cookies de sessão são o padrão em muitos frameworks, e o navegador envia o cookie quer o desenvolvedor tenha pensado nisso ou não. Por isso o CSRF continua nas listas da OWASP e da CWE.
bank.example. O navegador guarda o cookie de sessão para esse host.https://bank.example/transfer com valores escolhidos pelo atacante. Ele pode ser enviado assim que a página carrega, então a vítima não precisa clicar em nada.bank.example.Repare no que o atacante não precisou fazer. Ele não roubou o cookie, não adivinhou uma senha e não leu um único byte da resposta do banco. Quem fez o trabalho foi o navegador.
Uma requisição GET pode ser disparada sem formulário nenhum. Uma tag <img src="https://bank.example/delete?id=7"> faz o navegador enviar um GET assim que a página renderiza. Pré-carregamento, prévias de link e crawlers também enviam GET. Se uma rota GET altera dados, o CSRF não precisa de formulário.
A regra é simples: GET e HEAD leem dados e nunca os alteram. Mudanças passam por POST, PUT, PATCH ou DELETE. A regra sozinha não é uma defesa, porque um POST também pode ser forjado, mas todas as outras defesas funcionam melhor quando ela vale.
O servidor cria um token aleatório para cada sessão, coloca esse token em cada formulário como campo oculto e verifica o token em toda requisição que altera estado. Uma requisição sem o token certo é recusada.
O token funciona porque a página do atacante não consegue lê-lo. A política de mesma origem impede que evil.example leia o HTML devolvido por bank.example, então o atacante não copia o campo oculto para o formulário forjado. A requisição forjada chega com o cookie e sem o token.
O token precisa ser imprevisível (use um gerador aleatório criptográfico), estar ligado à sessão do usuário e ser checado em todo método não seguro, inclusive nos endpoints que o seu JavaScript chama. A maioria dos frameworks já traz essa proteção. A cheat sheet da OWASP coloca as defesas baseadas em token em primeiro lugar por esse motivo.
Cookies de double-submit são uma variante mais fraca. O token vai num cookie e num campo da requisição, e o servidor compara os dois. Um atacante que consiga definir cookies no seu domínio, por exemplo a partir de um subdomínio irmão, consegue burlar isso. Prefira o token sincronizado quando puder manter estado no servidor.
SameSite diz ao navegador quando anexar um cookie a uma requisição que começa em outro site. O atributo tem três valores.
Secure, então o cookie só trafega por HTTPS. Use None apenas quando um fluxo real entre sites precisa do cookie, como um widget de terceiros incorporado.A MDN documenta o atributo e seus valores na referência de Set-Cookie. O rascunho RFC 6265bis constrói "site" sobre o domínio registrável (o sufixo público mais o rótulo à esquerda, ou seja, site.example para www.site.example). Número de porta não conta. Subdomínios contam como o mesmo site. Isso importa no laboratório, porque localhost e 127.0.0.1 são hosts diferentes e, portanto, sites diferentes.
SameSite é forte, mas não é completo. Um subdomínio irmão como forum.bank.example é do mesmo site que bank.example, então Lax não impede um ataque partindo de lá. Navegadores antigos que ignoram o atributo enviam o cookie em qualquer lugar. E Lax ainda envia o cookie em navegações GET de nível superior, por isso a regra do GET continua valendo. Trate SameSite como uma camada.
Os navegadores enviam o cabeçalho Origin em requisições POST e na maioria das requisições entre sites. Ele traz o esquema, o host e a porta da página que iniciou a requisição, então o servidor pode compará-lo com a própria origem. Sec-Fetch-Site dá um sinal parecido, com valores como same-origin, same-site e cross-site.
A checagem é simples. Recuse requisições que alteram estado quando Origin existe e não bate com a sua origem. Se Origin não existir, use Referer. Se os dois faltarem numa requisição que altera estado, recuse. Interprete o cabeçalho como URL e compare a origem completa. Comparação por substring falha: Origin: https://bank.example.evil.test contém o seu domínio, mas pertence a outra pessoa.
Checagens de origem pegam requisições entre sites evidentes, mas dependem de cabeçalhos que alguns clientes omitem e que configurações de privacidade podem remover. Use como um filtro e mantenha um token para os casos em que os cabeçalhos não aparecem.
Esta é uma regra, não um controle que você testa com uma única requisição. Mantenha a regra porque ela faz todas as outras defesas cobrirem mais terreno. Uma rota que altera estado só em POST dá ao atacante um formulário para forjar, não uma tag de imagem para plantar.
Você precisa de Python 3.12 e de três janelas de terminal. A execução deste artigo usou Python 3.12.10 no Windows 11 com Git Bash. O laboratório tem um app Flask em 127.0.0.1:5000 e uma página estática simples em localhost:8001. A segunda origem é o que torna a requisição entre sites.
Crie um ambiente virtual e instale o Flask. No Linux e no macOS, ative com . .venv/bin/activate. No Windows com Git Bash, o script de ativação fica em .venv/Scripts/activate.
python3 -m venv .venv && . .venv/bin/activate && pip install flaskNesta execução, as versões instaladas foram Flask 3.1.3 e Werkzeug 3.1.9.
Salve como app.py. A rota /login define um cookie de sessão com SameSite=Lax e HttpOnly. A rota /transfer não altera nada. Ela só imprime se o cookie chegou, junto com dois cabeçalhos que ajudam a ler o resultado.
import os
from flask import Flask, make_response, request
app = Flask(__name__)
# Change this to "None" to compare. Browsers reject SameSite=None without Secure.
SAMESITE = os.environ.get("LAB_SAMESITE", "Lax")
SECURE = SAMESITE == "None"
@app.get("/login")
def login():
resp = make_response("logged in\n")
resp.set_cookie("session", "lab", samesite=SAMESITE, secure=SECURE, httponly=True)
return resp
@app.post("/transfer")
def transfer():
arrived = request.cookies.get("session") == "lab"
print(
f"POST /transfer cookie_arrived={arrived} "
f"origin={request.headers.get('Origin')} "
f"sec_fetch_site={request.headers.get('Sec-Fetch-Site')}",
flush=True,
)
return f"cookie_arrived={arrived}\n"O app ainda não valida nada. Isso é intencional. Você quer ver o que o navegador envia antes de adicionar uma defesa.
No primeiro terminal:
flask --app app run --host 127.0.0.1 --port 5000Em um segundo terminal, peça a rota de login e leia o cabeçalho:
curl -i http://127.0.0.1:5000/loginSaída desta execução, sem a linha Date:
HTTP/1.1 200 OK
Server: Werkzeug/3.1.9 Python/3.12.10
Content-Type: text/html; charset=utf-8
Content-Length: 10
Set-Cookie: session=lab; HttpOnly; Path=/; SameSite=Lax
Connection: close
logged inA linha Set-Cookie é a parte importante. HttpOnly mantém o cookie longe do JavaScript da página. Ele não impede o navegador de enviar o cookie, então HttpOnly não é uma defesa contra CSRF. SameSite=Lax é o atributo que muda o que o navegador envia entre sites.
Antes de envolver um navegador, envie as requisições você mesmo para saber o que o servidor registra:
curl -s -X POST -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://localhost:8001" -H "Sec-Fetch-Site: cross-site" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transfer
curl -s -X POST -H "Origin: http://127.0.0.1:5000" -H "Sec-Fetch-Site: same-origin" -b "session=lab" -d "amount=100" http://127.0.0.1:5000/transferRespostas desta execução, uma linha por comando:
cookie_arrived=False
cookie_arrived=True
cookie_arrived=TrueAs linhas que o terminal do Flask imprimiu para essas três requisições:
POST /transfer cookie_arrived=False origin=None sec_fetch_site=None
POST /transfer cookie_arrived=True origin=http://localhost:8001 sec_fetch_site=cross-site
POST /transfer cookie_arrived=True origin=http://127.0.0.1:5000 sec_fetch_site=same-originO curl não aplica SameSite. Ele envia o que você passar com -b. Então essas linhas provam que a rota lê o cookie e os cabeçalhos. Elas não provam o que um navegador envia. Esse é o próximo passo.
Crie uma pasta chamada attacker e salve este conteúdo em attacker/index.html:
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Second origin (lab)</title></head>
<body>
<h1>Form on a second origin</h1>
<form method="POST" action="http://127.0.0.1:5000/transfer">
<input type="hidden" name="amount" value="100">
<button type="submit">Send transfer</button>
</form>
</body>
</html>Em um terceiro terminal, inicie um servidor estático a partir da pasta que contém attacker/:
python3 -m http.server 8001 --bind 127.0.0.1Nesta execução, uma requisição ao servidor estático retornou HTTP 200. A página então abre em http://localhost:8001/attacker/. Observe o nome do host. localhost e 127.0.0.1 são hosts diferentes, então o formulário servido por localhost:8001 envia o POST para um site diferente do host do cookie. É essa a configuração entre sites de que você precisa.
Abra http://127.0.0.1:5000/login no seu navegador antes, para que o cookie fique guardado para 127.0.0.1 nesse perfil. Depois abra http://localhost:8001/attacker/ no mesmo navegador e clique em Send transfer. Leia o terminal do Flask.
Com SameSite=Lax, um POST de formulário entre sites não deve levar o cookie. A linha de log esperada é cookie_arrived=False. O navegador omite o cookie porque Lax não o envia em POST entre sites. A etapa no navegador não foi executada para este artigo, então confira a linha de log você mesmo contra essa expectativa.
Agora reinicie o app com None:
LAB_SAMESITE=None flask --app app run --host 127.0.0.1 --port 5000Leia o cabeçalho no novo servidor:
curl -i http://127.0.0.1:5000/loginSaída desta execução:
Set-Cookie: session=lab; Secure; HttpOnly; Path=/; SameSite=NoneSameSite=None sozinho não basta. Navegadores rejeitam um cookie None sem Secure, e Secure exige HTTPS. O laboratório serve HTTP simples em 127.0.0.1, e se o navegador guarda um cookie Secure vindo dessa origem depende do navegador. Abra o cookie nas ferramentas de desenvolvedor do navegador e veja se ele existe. Se não foi guardado, o log do servidor mostra cookie_arrived=False, mas o motivo é a regra do Secure, não o SameSite. Se foi guardado, envie o formulário de novo. Uma linha cookie_arrived=True com None significa que o navegador enviou o cookie em um POST entre sites. É esse comportamento que torna None arriscado para cookies de sessão, e é por isso que você deve usar Lax ou Strict, a menos que um fluxo real entre sites precise de None.
Pare cada servidor com Ctrl+C quando terminar.
O laboratório mostra como cookies e SameSite mudam o que o servidor recebe. Ele não adiciona defesa. A rota /transfer ainda aceita qualquer POST que carregue o cookie, então um cookie SameSite=None deixaria um formulário forjado ter sucesso. Para fechar a brecha, adicione um token sincronizado ao formulário e verifique-o na rota. O laboratório para aqui para que você veja primeiro o atributo sozinho.
evil-bank.example e bank.example.evil.test contêm bank.example. Compare origens completas.Leia a cheat sheet da OWASP do começo ao fim. Ela cobre os padrões de token, as checagens de cabeçalho e os casos que cada um deixa passar. Leia o rascunho RFC 6265bis para as regras exatas do SameSite, incluindo o padrão quando o atributo falta e como as navegações de nível superior são tratadas. A página Set-Cookie da MDN é uma referência mais rápida para os valores do atributo.
No seu próprio app, use a proteção contra CSRF que o seu framework oferece, e teste-a. Escreva um teste que envie um POST para uma rota que altera estado sem token e espere uma recusa. Escreva um segundo teste que envie um POST com um token de outra sessão e também espere uma recusa. Se usar Flask, veja a extensão CSRFProtect do Flask-WTF. Confira os avisos de cookie nas ferramentas de desenvolvedor do navegador, porque o Chrome informa problemas de SameSite no painel Issues.
Para a camada de transporte por baixo desses cookies, leia o que HTTPS e TLS fazem. O atributo Secure só tem sentido quando a conexão é criptografada. O próximo artigo desta série, como escrever seu primeiro relatório de segurança, mostra como registrar um achado como este para a equipe dona do código.
Segurança web e de APIs
O que é o OWASP Top 10, o que cada categoria de 2025 significa, como se defender e como mapear desafios do Juice Shop.
Segurança web e de APIs
Como a SQL Injection muda o sentido de uma consulta, por que consultas parametrizadas resolvem o problema e um laboratório com Juice Shop e sqlite3.
Segurança web e de APIs
Entenda XSS refletido, armazenado e baseado em DOM, por que a codificação depende do contexto e como a CSP reduz o risco, em laboratório local.