CSRF e CORS: SameSite cookies, preflight, tokens sincronizadores
8 min de leitura
Voce protegeu contra XSS. Mas ha outro ataque que nao exige codigo malicioso no seu app: CSRF (Cross-Site Request Forgery). O usuario logado no seu banco acessa um site malicioso, que faz um request pro seu banco com os cookies do usuario - e o banco processa como se fosse o usuario.
O exemplo classico: "clica aqui pra ver
o video de gatos" - link malicioso que
faz um POST escondido pra
banco.com/transferir?para=atacante&valor=1000.
O cookie de sessao do banco acompanha o
request (browser envia automaticamente).
O banco processa. $1000 transferidos.
CSRF existe ha 25 anos. So foi adequadamente mitigado em 2020 com SameSite=Lax (default em todos browsers modernos). Mas entender como funciona e o que CORS tem a ver com isso (sao ortogonais) ainda e' fundamental.
Se voce entende CSRF, SameSite, o papel de CORS, e o pattern de token sincronizador (double submit cookie), voce sai de "o cookie resolve" pra "defesa em profundidade: SameSite + token CSRF + verificar Origin".
O essencial 🟢
CSRF em uma frase: o browser envia cookies automaticamente, mesmo em requests cross-site. O atacante abusa disso - faz o usuario logado fazer um request pro seu app, e o cookie acompanha.
<!-- Site malicioso (atacante.com) -->
<form action="https://seu-banco.com/api/transferir" method="POST">
<input type="hidden" name="para" value="atacante">
<input type="hidden" name="valor" value="1000">
<button>Veja o video de gatos</button>
</form>
O usuario clica. O browser faz POST
pra seu-banco.com/api/transferir. O
cookie de sessao do banco acompanha.
Banco processa. $1000 transferidos
sem o usuario saber.
Por que SameSite resolve 90% do
problema. O atributo SameSite no
cookie diz ao browser quando enviar o
cookie em requests cross-site:
| Valor | Comportamento |
|---|---|
Strict | Cookie so em requests same-site (mesmo dominio). Maxima protecao. |
Lax (default desde 2020) | Cookie em same-site + GET cross-site (links de outros sites). Boa protecao. |
None | Cookie em qualquer request. Requer Secure (HTTPS). |
Default moderno e' Lax. Quando
voce faz Set-Cookie: session=abc; HttpOnly,
o browser ja assume SameSite=Lax se nao
especificado. Significa:
- Request de
atacante.compro seu banco: cookie NAO envia. CSRF prevenido. - Link de
google.compro seu banco (GET): cookie envia. UX preservada. - Form POST cross-site: cookie NAO envia. CSRF prevenido.
A excecao: <form method="GET"> em
Lax. SameSite=Lax envia cookie em GET
cross-site. Ate form method=GET funciona.
Mas GET nao deveria ter efeitos
colaterais (regra HTTP). Logo, CSRF via
GET nao importa - GET so le.
CORS - o "permission system" do browser pra requests cross-origin. CORS (Cross-Origin Resource Sharing) e' uma politica do browser, nao do server. O server diz "permito", o browser enforca. Sem CORS, o browser bloqueia request cross-origin por default.
// Seu frontend em app.com faz fetch pra api.com
fetch("https://api.com/users")
.then((r) => r.json());
// Browser: "Origem app.com pedindo api.com - tem header Access-Control-Allow-Origin?"
// Se nao: bloqueia o response (mas o request chegou ao server).
O server precisa responder com:
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true # so se envia cookies
CORS NAO e' seguranca do server. CORS protege o browser de mostrar response cross-origin. Mas o request chega ao server (CORS e' browser-side). Se o server nao protege, o request e' processado mesmo com CORS bloqueando o response no browser.
A regra: CORS protege o usuario (nao vazar dados via cross-origin read), NAO protege o server (server tem que validar autorizacao sempre).
Preflight - quando o browser pergunta
"posso?" antes de fazer. Requests
"complexos" (POST com Content-Type: application/json,
PUT/DELETE, custom headers) exigem um
preflight - um OPTIONS request
preliminar perguntando se o server
libera:
OPTIONS /api/users HTTP/1.1
Origin: https://app.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
Apos preflight OK, browser faz o request real. Se preflight falha, request nao acontece.
O credentials no fetch - quando
cookies acompanham. Por default, fetch
nao envia cookies cross-origin. Pra
enviar:
// Frontend em app.com chama api.com
fetch("https://api.com/users", {
credentials: "include", // envia cookies
});
Mas credentials: "include" exige que o
server retorne Access-Control-Allow-Credentials: true
E Access-Control-Allow-Origin nao pode
ser * (tem que ser a origem exata).
# ERRADO - nao funciona com credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
# CERTO
Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Credentials: true
mode: "cors" vs mode: "no-cors" vs
mode: "same-origin". O fetch aceita
3 modes:
cors(default): request cross-origin, CORS aplicado. Se server nao permitir, response bloqueado.no-cors: request cross-origin SEM CORS. Response opaca (nao da pra ler body, so status). Util pra "fire and forget" (analytics, beacon).same-origin: so libera request same-origin. Cross-origin falha.
// Beacon de analytics - no-cors, nao importa o response
navigator.sendBeacon("/log", JSON.stringify({ event: "click" }));
// sendBeacon ja e' no-cors por design.
CSRF token (double submit cookie) - a defesa classica alem de SameSite. Pra apps de alta seguranca, alem de SameSite, voce usa CSRF token:
- Server gera token random, setta em cookie (NAO httpOnly - JS precisa ler).
- Frontend le o cookie, inclui o token em header custom ou input hidden em todo POST/PUT/DELETE.
- Server valida que o token do cookie == token do request.
// 1. Server setta cookie
res.cookie("XSRF-TOKEN", randomToken, {
httpOnly: false, // JS precisa ler!
sameSite: "Lax",
secure: true,
});
// 2. Frontend le cookie, inclui em header
const token = document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1];
fetch("/api/transferir", {
method: "POST",
headers: { "X-CSRF-Token": token },
credentials: "include",
body: JSON.stringify({ valor: 1000 }),
});
// 3. Server valida
app.post("/api/transferir", (req, res) => {
if (req.headers["x-csrf-token"] !== req.cookies["XSRF-TOKEN"]) {
return res.status(403).send("CSRF token invalido");
}
// processa transferencia
});
Por que funciona: site malicioso nao consegue ler o cookie XSRF-TOKEN do seu banco (cross-origin, sem CORS), logo nao consegue incluir o token no header. Token no request == token no cookie == legitimo. E o double-submit (mesmo token em 2 lugares - cookie + header/request body) garante que o atacante nao consegue forjar.
Origin e Referer headers - verificacao
alternativa. Server pode rejeitar
requests com Origin ou Referer que
nao correspondem ao seu dominio:
app.post("/api/transferir", (req, res) => {
const origin = req.headers.origin || req.headers.referer;
if (!origin || new URL(origin).hostname !== "seu-banco.com") {
return res.status(403).send("Origem invalida");
}
// processa
});
Origin e' setado pelo browser em todos
os POST/PUT/DELETE cross-origin. O
atacante nao pode forjar (browser
controla). Verificacao simples que pega
90% dos CSRF.
Aprofundamento 🟡
CORS e credenciais: a restricao
"origin nao pode ser *". Quando o
server quer aceitar cookies (credentials:
include) de um origin especifico, NAO
pode retornar *. Tem que ser a origin
exata. E o navegador so aceita 1 origin
por vez (sem listas). Workaround: ler
o header Origin do request e echo
dinamicamente:
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
next();
});
CORS preflight cache. O preflight (OPTIONS) e' caro (round-trip extra). O browser cacheia o resultado via:
Access-Control-Max-Age: 86400
86400 = 24h. Durante 24h, requests do mesmo tipo nao fazem preflight. Ate o tempo expirar. Util em apps com alta frequencia de POST cross-origin.
CORS proxy / dev proxy. Em dev local, evita CORS usando um proxy (Vite, webpack dev server):
// vite.config.ts
export default {
server: {
proxy: {
"/api": "http://localhost:3001", // dev backend
},
},
};
Seu app chama /api/users (mesmo origin).
Vite faz proxy pra http://localhost:3001/api/users.
CORS nao se aplica (mesmo origin pro
browser). Em prod, app.com e api.com sao
origins diferentes - CORS real.
fetch + credentials + SameSite=None -
a combinacao pra third-party cookies. Se
voce tem um iframe de terceiro que precisa
de cookies (ex: embed de Stripe Checkout),
use SameSite=None; Secure:
Set-Cookie: session=abc; SameSite=None; Secure
Secure exige HTTPS. SameSite=None libera
cross-site. CUIDADO: third-party cookies
estao sendo removidos pelos browsers
(Chrome 2024+, Safari ja removeu, Firefox
em 2024+). Em 2026, a tendencia e' third-party
storage APIs (Storage Access API) que
exigem gesture do usuario. Pra maioria
dos casos, evite third-party cookies.
Sec-Fetch-Site header - o indicador
que o browser envia. Modern browsers
incluem Sec-Fetch-Site: same-origin | same-site | cross-site | none em requests.
Server pode usar pra rejeitar cross-site
requests sem precisar de CORS:
app.post("/api/transferir", (req, res) => {
if (req.headers["sec-fetch-site"] !== "same-origin") {
return res.status(403).send("Cross-site bloqueado");
}
// processa
});
Preferir isso a verificar Origin
(mais robusto, mais novo, browser-controlled).
CORS bypass - o que atacantes tentam.
nullorigin: sandboxes (iframe comsandbox,data:URLs) tem originnull. Server que aceitanullemAccess-Control-Allow-Originesta' exposto a qualquer sandbox. Nunca aceitenull.- Wildcard subdomain:
*.example.comcobreattacker.com? Nao. Masexample.com.attacker.com? Nao. Mas subdominio confiavel:*.example.comcobreapi.example.com(legit) eevil.example.comse for subdomain. Cuidado com quem controla subdomains. - CORS sem credentials + cross-origin write: o atacante usa o user como proxy (XSS ou phishing). CORS nao ajuda nesse caso - a vitima esta' autenticada.
Pattern BFF (Backend For Frontend) - o
jeito moderno de evitar CORS/CSRF. Em
vez de SPA + API publica, use SPA + BFF
(coberto em backend):
Browser (SPA) ─cookie httpOnly─> BFF (mesmo dominio)
│
│ token de servico
v
API publica
- Browser fala so com mesmo origin (BFF). Sem CORS, sem CSRF (cookie httpOnly + same-site garante).
- BFF fala com API com token de servico (nao cookie). Sem browser no caminho.
- XSS no SPA nao rouba cookie (httpOnly).
- CORS/CSRF eliminados por design.
Pattern recomendado em 2026 pra apps sensiveis. BFF + cookie httpOnly + SPA = a melhor pratica.
Pra quem quer ir mais alem 🔴
Por que SameSite=Lax e' default desde
2020 e nao SameSite=Strict. O default
Lax e' um tradeoff consciente:
Strictquebra links de email/Slack/ redes sociais pro seu site (cookie nao envia no 1o request, usuario nao "reconhece" como logado).Laxlibera GET cross-site (links), bloqueia POST/PUT/DELETE cross-site. Cobre 95% dos casos de UX.
Double Submit Cookie vs
Synchronizer Token Pattern. Dois
padroes classicos:
- Synchronizer Token (stateful): server armazena tokens em sessa. Valida via lookup. Mais seguro, mais state.
- Double Submit (stateless): token no cookie + no request. Server compara os 2. Sem state, mais simples, suficiente pra 99% dos casos.
Em 2026, double submit e' o default. Synchronizer so em apps de alta seguranca (banking, gov).
Content-Security-Policy: frame-ancestors 'none' - o anti-clickjacking. O
clickjacking e' similar a CSRF: site
malicioso carrega seu site num iframe
invisivel, usuario clica sem saber.
Mitigacao: header X-Frame-Options: DENY
ou CSP frame-ancestors 'none'. Cobre em
csp-e-headers.
Subdomain takeover + CORS. Se voce tem
*.example.com e alguem consegue registrar
api-staging.example.com (DNS dangling),
o attacker controla o subdomain - e o
CORS allow *.example.com aceita requests
de la. Audite subdomains regularmente
pra evitar dangling DNS.
Access-Control-Expose-Headers - deixar
response headers visiveis pro JS. Por
default, so 7 headers sao expostos
ao JS (Cache-Control, Content-Language,
Content-Type, Expires, Last-Modified,
Pragma). Pra expor custom (ex: X-Request-Id):
Access-Control-Expose-Headers: X-Request-Id, X-RateLimit-Remaining
CORS com credenciais + wildcard e
impossivel por design. O spec CORS
proibe * com credentials: true - foi
decisao consciente. Forca o server a
escolher origins especificas. E' uma
feature, nao bug - evita bypass de
credential.
Leitura recomendada:
- OWASP - CSRF - referencia OWASP sobre CSRF.
- MDN - CORS - doc oficial MDN, completa.
- MDN - SameSite cookies - doc oficial do SameSite.
- PortSwigger - CORS - lab interativo pra testar CORS.
Dica: o erro mais comum em CSRF/CORS e' achar que CORS protege o server. CORS protege o browser de ler responses cross-origin. O request chega ao server mesmo com CORS bloqueado no browser. Se o server confia em CORS pra seguranca, o atacante faz requests diretamente (via curl, sem browser) e CORS nao ajuda. Sempre: server valida autorizacao, CORS e' defesa secundaria.
No proximo no, vamos CSP e headers de
hardening: Content-Security-Policy em
detalhe, Strict-Transport-Security,
X-Content-Type-Options, Referrer-Policy,
Permissions-Policy, e o helmet.js que
aplica o baseline de seguranca em
Express/Node.
// Quiz
Por que CORS nao protege o server - apenas o browser?