Pular para o conteúdo principal
Versão: main (dev) 🚧

ADR-008 — CSRF: emissão anônima de token + validação gated por sessão autenticada

Date: 2026-06-16 Status: Accepted Supersedes: seção CSRF de ADR-006 (o restante do ADR-006 permanece vigente).

Context

O primeiro projeto consumidor do template reportou um deadlock de login no caminho BFF (browser, cookie HttpOnly):

Um cookie de sessão inválido/expirado tranca o usuário fora do login. Não dá pra logar (o BffAntiforgeryMiddleware exige token CSRF porque o cookie existe) nem pra obter o token (GET /v1/auth/csrf exigia autenticação). A única saída é limpar cookies manualmente.

A análise (ver docs/analysis/csrf-auth-deadlock-analysis.md) confirmou o bug como uma incoerência interna entre duas peças do canon, cada uma defensável isoladamente:

PeçaCritério original
BffAntiforgeryMiddlewareexigia CSRF quando o cookie estava presente (Cookies.ContainsKey)
GET /v1/auth/csrfsó emitia token para quem estava autenticado (.RequireAuthorization())

Um cookie presente porém inválido (não autentica) satisfazia o gatilho da primeira peça e reprovava no requisito da segunda. Como o POST /v1/auth/session (login) é um verbo de escrita que passa pelo middleware antes da autorização do endpoint, ele passava a exigir um token CSRF que o usuário não tinha como obter — e o logout (também RequireAuthorization) não destravava, pois o cookie é HttpOnly (JS não o apaga).

O gatilho é comum: o cookie de sessão é emitido sem IsPersistent (session cookie, sem Max-Age), então o browser o mantém até fechar a janela enquanto o ticket interno expira em 8h (ExpireTimeSpan). Uma aba aberta por mais de 8h já reproduz: cookie presente, ticket vencido. Rotação/perda da chave de Data Protection (multi-instância sem keyring compartilhado, restart de container, expiração de chave) e cookie adulterado produzem o mesmo estado.

O conceito de segurança correto: CSRF só existe quando há credencial ambiente que confere autoridade. Um cookie inválido não confere autoridade nenhuma — não há sessão a sequestrar, logo não há risco de CSRF. O canon, ao escrever a regra como "cookie presente", aproximou "tem credencial ambiente com autoridade" por "tem o cookie", e essas duas coisas divergem exatamente no estado presente-mas-inválido. Note que o canon isentava o login do caso "sem cookie nenhum" — logo, exigir CSRF no login só quando há um cookie morto presente nunca foi uma decisão de segurança deliberada.

Decision

Duas mudanças complementares, ambas no template (o BffAntiforgeryMiddleware e o AuthEndpoints vivem em ITLab.Template.Next.Api; o DevPack não participa do gatilho de CSRF).

B — validação gated por sessão autenticada, não por presença

BffAntiforgeryMiddleware.RequiresValidation passa a exigir CSRF apenas quando o request carrega o cookie de sessão E está autenticado:

return context.Request.Cookies.ContainsKey(AuthCookieDefaults.SessionCookieName)
&& context.User.Identity?.IsAuthenticated == true;
  • O estado autenticado está disponível porque UseAuthentication roda antes de UseBffAntiforgery no pipeline (Program.cs).
  • A presença do cookie permanece como pré-filtro barato — e mantém o TestAuthenticationHandler (header X-Test-Roles, sem cookie) isento, preservando os testes de escrita do canon.
  • Cookie stale/inválido → não autenticado → CSRF não exigido → login (e logout) voltam a funcionar; escritas após login válido seguem protegidas normalmente.

A — emissão de token CSRF anônima

GET /v1/auth/csrf passa de .RequireAuthorization() para .AllowAnonymous(). Emitir um token antiforgery (IAntiforgery.GetAndStoreTokens) não requer identidade — depende apenas do cookie antiforgery próprio (.AspNetCore.Antiforgery.*), distinto do cookie de sessão. O SPA passa a sempre conseguir um token antes de qualquer POST.

As duas são compatíveis e adotadas juntas: B corrige a causa-raiz (alinha o código à intenção já documentada de "CSRF só em escrita cookie-borne com autoridade"); A é a defesa adicional convencional (endpoint emissor de antiforgery anônimo é o padrão da plataforma).

Consequences

Positivas:

  • Elimina o deadlock: cookie inválido/expirado não tranca mais o login. Recuperação automática sem limpar cookies à mão.
  • O gatilho do middleware passa a expressar literalmente o conceito de segurança ("CSRF só com autoridade ambiente").
  • Nenhuma regressão de proteção: escrita por sessão autenticada sem token continua 400 (coberto por AntiforgeryTests + BffAntiforgeryMiddlewareTests).

Negativas / limitações:

  • O harness de teste de integração autentica todo request (TestAuthenticationHandler retorna sempre Success), então não consegue exercitar o caminho "cookie presente mas não-autenticado". A verificação fiel da Option B vive em unit tests do middleware (BffAntiforgeryMiddlewareTests), com HttpContext construído à mão; a Option A é assertada via metadata do endpoint (Csrf_Endpoint_AllowsAnonymous), não por round-trip HTTP.
  • O login segue não protegido por CSRF (como já era no caso sem cookie). É um trade-off aceito do canon — "login CSRF" não está no modelo de ameaça do BFF same-origin (ADR-007) com SameSite=Lax.

Sem impacto:

  • SPA (Spa/src/lib/api/csrf.ts) não muda: já buscava o token sem premissa de identidade; com a Option A a busca passa a funcionar antes do login.
  • DevPack: nenhuma alteração nem bump de versão (correção 100% template-side).

Alternatives considered

OpçãoPor que não isolada
Só A (endpoint anônimo)Resolve o sintoma, mas deixa o gatilho do middleware ainda por "presença" — CSRF "teatral" num login que o canon nem pretende proteger.
Só B (gating por autenticação)Suficiente para destravar, mas manter o endpoint emissor atrás de auth contraria o padrão usual da plataforma e é gratuito. Adotar A junto é barato e defensivo.
Gating por IsAuthenticated sem o pré-filtro de cookieQuebraria os testes do canon: o TestAuthenticationHandler autentica todo request sem cookie → CSRF passaria a ser exigido em toda escrita de teste. O && com ContainsKey é o que preserva a isenção do scheme de teste.
Expirar o cookie por Max-Age (cookie persistente) para o browser apagá-lo no vencimentoNão cobre rotação de chave nem adulteração; muda a semântica de sessão (persistente vs. de sessão) por um motivo que não é o do bug.

References