From dc56b9ed7649253915e1a0a2ba228cccf9f04609 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Tue, 25 Aug 2026 14:57:49 -0300 Subject: [PATCH] =?UTF-8?q?fix(campanha):=20guarda=20CSRF=20mesma-origem?= =?UTF-8?q?=20proxy-aware=20atr=C3=A1s=20do=20t=C3=BAnel=20(#913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog/2026-08-25-c146.md | 1 + ...rigir-guarda-mesma-origem-campanha-impl.md | 88 +++++++++++++ src/utilities/sameOriginRequest.ts | 57 ++++++++- tests/unit/sameOriginRequest.unit.spec.ts | 120 ++++++++++++++++++ 4 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 docs/changelog/2026-08-25-c146.md create mode 100644 docs/plans/corrigir-guarda-mesma-origem-campanha-impl.md create mode 100644 tests/unit/sameOriginRequest.unit.spec.ts diff --git a/docs/changelog/2026-08-25-c146.md b/docs/changelog/2026-08-25-c146.md new file mode 100644 index 00000000..f68b8db4 --- /dev/null +++ b/docs/changelog/2026-08-25-c146.md @@ -0,0 +1 @@ +**C146 (#913) — guarda CSRF de mesma-origem de `/campanha` proxy-aware (fix do "Requisição inválida." atrás do túnel):** `isSameOriginRequest` (`src/utilities/sameOriginRequest.ts`) passou a comparar o header `Origin` contra duas bases: a literal de `request.url` (legado, verbatim — dev/portas de worktree/TLS direto) **ou** a origem pública resolvida pela política estabelecida de `getCampaignInviteBaseURL` (`campaignInviteOrigin.ts`, intocado: `x-forwarded-*` para autoridades locais, `NEXT_PUBLIC_SITE_URL` HTTPS-DNS-público em produção, fail-closed). Atrás do cloudflared, `request.url` chega `http://localhost:3000` enquanto o navegador envia `Origin: https://jorgesolla1313.com.br` — antes, mismatch → 403 "Requisição inválida." no autosave de estimativas (`POST /campanha/municipios/pledge-estimated-votes` e as 14 rotas do wrapper). Sem migration, sem UI, sem mudança de call site: superconjunto estrito da base legada (cross-origin continua 403; ausência de `Origin` continua aprovando). Spec novo `tests/unit/sameOriginRequest.unit.spec.ts` (matriz: túnel prod, fail-closed sem env, cross-origin, header encadeado, dev porta-diferente, TLS local); spec do wrapper intacto como pin. `NEXT_PUBLIC_SITE_URL` confirmado presente no stack de produção; smoke e2e do autosave de estimativas verde. diff --git a/docs/plans/corrigir-guarda-mesma-origem-campanha-impl.md b/docs/plans/corrigir-guarda-mesma-origem-campanha-impl.md new file mode 100644 index 00000000..861b992c --- /dev/null +++ b/docs/plans/corrigir-guarda-mesma-origem-campanha-impl.md @@ -0,0 +1,88 @@ +# Impl: Corrigir guarda de mesma origem do CSRF em campanha + +Status: aprovado +Atualizado em: 2026-08-25 +Issue: #913 +Intenção: docs/plans/corrigir-guarda-mesma-origem-campanha.md +Appetite restante: herdado — ~0,5 dia de engenharia; sem migration, sem collection, sem UI + +## Leitura da intenção + +- **Outcome:** `POST /campanha/municipios/pledge-estimated-votes` (e toda mutation JSON de `/campanha`) volta a funcionar atrás do túnel Cloudflare: hoje `isSameOriginRequest` compara `Origin: https://jorgesolla1313.com.br` contra `request.url` cru (`http://localhost:3000/...` atrás do cloudflared) e responde 403 `"Requisição inválida."` antes de parsear o corpo. O autosave das células precisa voltar a gravar em produção, mantendo o CSRF de mesma origem intacto. +- **O que NÃO negociar:** (1) rejeição de cross-origin permanece fail-closed — nada de aceitar qualquer origem; (2) a guarda continua estrutural no `campaignJsonMutationRoute` (nenhuma rota ganha linha própria de origem); (3) zero regressão em `campaignInviteOrigin.ts`, nas rotas de convite/WebAuthn/calendário que o usam, e nos contratos de resposta dos dois consumidores diretos de `isSameOriginRequest` (`ai-transcribe` 403 `{error}`, `social-feed/sync` 403 `{ok:false}`); (4) dev/test local em qualquer porta (worktrees 3100+) e esquema segue aprovando requisições do próprio app. +- **O que reavaliar:** questão aberta assumida na intenção — a fonte da origem esperada são os headers `x-forwarded-*` com fallback `NEXT_PUBLIC_SITE_URL`. Fechada pela leitura do código: `getCampaignInviteBaseURL` JÁ implementa exatamente essa política (headers só para autoridades locais em dev/test; origem canônica de config em produção, HTTPS DNS público ou throw) — a decisão é reuso, não reimplementação. + +## Abordagem recomendada + +```mermaid +flowchart TD + A["POST com header Origin"] -->|"ausente"| OK["seguir (parse/handler)"] + A -->|"presente"| B{"origin ==
request.url?"} + B -->|sim| OK + B -->|nao| C{"origin == origem resolvida
getCampaignInviteBaseURL
(headers + env)"} + C -->|sim| OK + C -->|"throw ou mismatch"| R["403 Requisição inválida."] +``` + +**Opções consideradas:** A | B | C + +- **A)** Reconstruir inline a origem cliente-visível (`x-forwarded-proto` + `x-forwarded-host ?? host`, fallback `request.url`) com parser próprio dentro de `sameOriginRequest.ts`. +- **B)** Estender `isSameOriginRequest` para uma **união aditiva de bases**: aprova se `Origin` casa com a origem de `request.url` (base legada, comportamento atual preservado) **ou** com a origem resolvida por `getCampaignInviteBaseURL({ requestOrigin, forwardedHost: x-forwarded-host ?? host, forwardedProto })`, capturando throw como "base indisponível". +- **C)** Comparar apenas host/autoridade (precedente WebAuthn, `campaignWebAuthnConfig.ts:26–49`). + +**Recomendação:** **B** — porque reusa o módulo dono da política de origem (parsing com rejeição de vírgulas/CRLF/credenciais/portas já testado em `tests/int/campaignInvite.int.spec.ts`; "edit the owner, don't twin"), fecha o buraco exato do túnel (produção ignora headers spoofáveis para nomes públicos e compara contra `NEXT_PUBLIC_SITE_URL`, que exige HTTPS DNS público ou falha fechada) e é **superconjunto estrito** do comportamento atual: nenhuma requisição que passava antes passa a ser rejeitada, logo os 16 consumidores (14 rotas via wrapper + `ai-transcribe` + `social-feed/sync`) herdam a correção sem risco de falso-negativo. Assinatura síncrona `boolean` preservada — nenhum call site muda. + +**Rejeitadas:** A — twina validação de headers que `campaignInviteOrigin` já possui, confia em `x-forwarded-host` client-controlável também em produção (só não explorável porque custom headers exigem CORS preflight — argumento sutil demais para um guard de segurança) e adiciona ~30 linhas novas de casos de borda; C — host-only tolera divergência http↔https (cloudflared termina TLS na edge) e troca o contrato de _origem_ da guarda por _autoridade_, mudança de política que a intenção não pede. Também rejeitado por anti-goal: patchear as 14 rotas individualmente (espalhar lógica) e relaxar a comparação (aceitar qualquer origem). + +### Componentes / mudanças + +- **`isSameOriginRequest`** (`src/utilities/sameOriginRequest.ts`, editado): nova semântica união — `Origin` ausente → `true`; senão `new URL(origin).origin === new URL(request.url).origin` (legado, verbatim) OU `=== new URL(getCampaignInviteBaseURL({ requestOrigin: origin, forwardedHost: request.headers.get('x-forwarded-host') ?? request.headers.get('host'), forwardedProto: request.headers.get('x-forwarded-proto') })).origin`, com `try/catch` em torno da resolução (throw ⇒ base descartada, decide o legado); `Origin` não-parseável ⇒ `false`. Ganha `import 'server-only'` (transitivamente server-only pelo novo import; alias de teste já existe em `vitest.unit.config.mts`). Doc comment reescrito: documenta as classes de consumidor e a política duas-bases (direta vs atrás-de-proxy). +- **`tests/unit/sameOriginRequest.unit.spec.ts`** (novo — o módulo nunca teve spec próprio; cobertura vinha só via wrapper): matriz com `vi.stubEnv` + `vi.unstubAllEnvs()` (unit NÃO carrega `.env.test`). Casos: Origin ausente→true; Origin==request.url→true; **tracer do túnel** — `new Request('http://localhost:3000/campanha/x')` + `Origin: https://jorgesolla1313.com.br` + `x-forwarded-proto: https`/`x-forwarded-host: jorgesolla1313.com.br` + `NODE_ENV=production`+`NEXT_PUBLIC_SITE_URL=https://jorgesolla1313.com.br` →true; mesmo tracer com `Origin: https://evil.example` →false; produção SEM `NEXT_PUBLIC_SITE_URL` →false (fail-closed); `x-forwarded-host: 'localhost:3000,evil.example'` encadeado →decide base legada sem crash; Origin `'null'`/inválido →false; dev TLS direto `Request('https://localhost:3443')`+Origin igual →true (cobre aceite #3). +- **`tests/unit/campaignJsonMutationRoute.unit.spec.ts`**: **intacto e verde** — seus dois testes de origem continuam válidos sob a união (cross-origin `evil.example` não casa com nenhuma base; Origin==request.url casa na legada). Não "consertar" esses testes: eles são o pin da base legada. +- **Migration:** sem migration +- **Access / Consent:** N/A +- **UI:** N/A (Impeccable A) + +## Fases verificáveis + +1. **Tracer / server** — spec novo RED com o caso-túnel (falha no código atual: `request.url` interno ≠ Origin público); implementar a união em `sameOriginRequest.ts` até GREEN com a matriz completa; rodar os testes do wrapper confirmando que os existentes ficam verdes sem edição. Verificação operacional (leitura, não código): confirmar que `NEXT_PUBLIC_SITE_URL` está exportado no runtime de produção (`~/stack/teqo-1313.env`) — sem ele a base canônica descarta-se silenciosamente (catch) e o sintoma persiste apesar do deploy verde. +2. **Gates** — `pnpm gate:fast`; cascata completa antes do PR (`tsc --noEmit && pnpm lint && pnpm format:check && pnpm exec knip && pnpm check:cycles && pnpm test && pnpm build` — knip garante que o import novo não órfã nada); smoke e2e local do autosave alvo provando que o fluxo segue gravando em dev direto; push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Tocar em `campaignInviteOrigin.ts`, `campaignWebAuthnConfig.ts` ou no envelope/respostas de `campaignJsonMutationRoute.ts` — o precedente host-vs-origin do WebAuthn tem comentário próprio explicando por que diverge; não unificar. +- Plugar `allowLocalTLS` na guarda: a normalização `https://localhost`→`http` fora de produção é política vigente (nenhum call site hoje passa `allowLocalTLS:true`); o aceite "com TLS" fica coberto pela base legada (`request.url` reflete o esquema real do acesso direto). Plumbing novo de env para TLS local é especulação. +- Harness e2e de túnel (cloudflared local): caro e frágil; a matriz unit cobre a lógica, e a prova final é o smoke manual pós-deploy com `curl -H 'Origin: https://jorgesolla1313.com.br' -X POST .../pledge-estimated-votes` esperando 400 (passou da guarda, corpo inválido) em vez de 403. +- Mudar os contratos `{error}`/`{ok:false}` das duas rotas diretas, nem mover `social-feed/sync`/`ai-transcribe` para o wrapper (allowlist da convenção tem razão declarada própria). +- Registrar pin novo: nenhum arquivo top-level novo em `utilities/` — estendemos o `sameOriginRequest.ts` já pineado (`codebaseConventions.unit.spec.ts`), então o teste de convenção não precisa de edição. + +## Riscos e mitigação + +- **`NEXT_PUBLIC_SITE_URL` ausente/incorreto no runtime de produção** → base canônica lança/difere, catch descarta, legado decide, e o 403 do túnel persiste silenciosamente. Mitigação: fase 1 verifica o env do stack; smoke curl pós-deploy valida o outcome #1 de verdade (deploy verde ≠ bug corrigido neste caso). +- **Falso-positivo adicional (aceitar mais que antes):** a única base nova em produção é a origem canônica de config, validada como HTTPS DNS público e imune a headers do cliente; em dev/test a resolução aceita valores a mais, mas não é superfície de ataque. Superconjunto estrito ⇒ impossível regressar negativo nos consumidores existentes. +- **Vazamento de `vi.stubEnv('NODE_ENV')` entre testes** do spec novo → `afterEach(vi.unstubAllEnvs)` e casos independentes; NODE_ENV stubado só dentro dos `it` do túnel/fail-closed. +- **Valores encadeados `x-forwarded-host: a, b`** (proxy duplo): chegam à guarda e são rejeitados pelo parser reusado ⇒ caem na base legada/config — comportamento fixado por caso de teste, sem parser paralelo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: autosave atrás do túnel sem "Requisição inválida."; cross-origin segue 403; dev/test localhost ±TLS e portas de worktree aprovam; zero regressão em `campaignInviteOrigin.ts` e suas rotas consumidoras +- [ ] Invariantes AGENTS/engineering-standards: editou o dono (`sameOriginRequest.ts`) em vez de gemelar parsing; identificadores em inglês, copy pt-BR existente intacta; sem migration/push:false; `server-only` onde virou módulo de servidor; nenhum URL público alterado +- [ ] Testes de domínio previstos: unit novo da matriz de origem (túnel/cross-origin/fail-closed/encadeado/TLS-local/dev-porta-diferente) + spec do wrapper intacto como pin da base legada; convenção-test de POST routes e pin top-level passam sem edição + +## Triage pós-entrega (capture-review-debts, 2026-08-25) + +### Já resolvido na sessão (não reabrir) + +- Relaxamento dev-only (Origin localhost em porta ≠ request.url aprovado em dev/test): documentado no doc comment do módulo e pinado pelo teste "approves a dev request whose local Origin differs from the request URL port" (`tests/unit/sameOriginRequest.unit.spec.ts`). +- Gaps de cobertura apontados pelos revisores (isolamento do pin da base legada, caso dev porta-diferente, nome do teste de header encadeado): aplicados na sessão. + +### Adiado com gatilho + +- **S3 — extração da tripla `x-forwarded-host ?? host`/`x-forwarded-proto`/`requestOrigin`:** 2 call sites (`campaignInviteOrigin.ts` e `sameOriginRequest.ts`). Extrair quando surgir o 3º call site da política de origem proxy-aware **ou** quando a política ganhar parâmetro novo (ex.: `allowLocalTLS`) — aí são 2 pontos a manter em sincronia. + +### Explicitamente fora + +- **S2 — rename `campaignInviteOrigin.ts`** (o módulo virou a política de origem proxy-aware do app, nome diz "invite"): rename de pureza, score ≤2 — não desbloqueia reuso real. +- **S4 — wraps defensivos** (`new URL(getCampaignInviteBaseURL(...))` no-op hoje; catch da base legada inalcançável na prática): decisão mantida, defesa barata. +- **K1 — knip quebrado localmente** (pré-existente em main; `Error loading src/payload.config.ts` server-only guard; CI verde): fora do escopo desta entrega — registrar como Issue separada se o humano pedir. +- **K2 — `pnpm test:e2e -- -g `** (o `--` repassado faz o playwright tratar `-g` como filtro posicional; workaround: `node_modules/.bin/playwright test ...` direto): pré-existente, fora do escopo — registrar se o humano pedir. diff --git a/src/utilities/sameOriginRequest.ts b/src/utilities/sameOriginRequest.ts index 51a9ebd1..d2b8a1b0 100644 --- a/src/utilities/sameOriginRequest.ts +++ b/src/utilities/sameOriginRequest.ts @@ -1,16 +1,63 @@ +import 'server-only' + +import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin' + /** * CSRF guard for cookie-authenticated JSON route handlers (auto-save popovers): * a same-origin browser request either omits `Origin` (same-site navigation) - * or sends one matching the request URL; anything else is rejected before the - * body is even parsed. Its single consumer is `campaignJsonMutationRoute`, - * which is how every `POST /campanha/**` endpoint gets it: applying the policy - * here and the sharing there is what stops a new route from omitting it. + * or sends one matching an origin this deployment answers as. Two bases are + * accepted, and both must hold for every consumer: + * + * - the literal `request.url` base — what a browser sends when it reaches this + * server directly (dev, worktrees, direct TLS); + * - the proxy-aware public base resolved with the established + * `getCampaignInviteBaseURL` policy (`x-forwarded-*` for local authorities, + * `NEXT_PUBLIC_SITE_URL` — HTTPS public DNS or throw — in production). This + * is what a browser sends behind the Cloudflare tunnel, where `request.url` + * still carries the internal `http://localhost:3000` origin. + * + * Resolving the second base can throw (production misconfiguration); the + * failure is contained to that base so the decision falls to the first. + * Anything that matches neither is rejected before the body is even parsed. + * + * The `Origin` under verification doubles as the resolver's `requestOrigin` + * input: production ignores it (the configured site URL decides), while + * dev/test accept any localhost/127.0.0.1/[::1] port through it — a dev-only + * relaxation, never a production one. + * + * Every `POST /campanha/**` endpoint inherits this through + * `campaignJsonMutationRoute`; the two remaining JSON routes + * (`campanha/api/ai-transcribe`, `api/social-feed/sync`) call it directly and + * keep their own rejection envelopes. */ export const isSameOriginRequest = (request: Request): boolean => { const origin = request.headers.get('origin') if (!origin) return true + + let originURL: URL try { - return new URL(origin).origin === new URL(request.url).origin + originURL = new URL(origin) + } catch { + return false + } + + try { + if (originURL.origin === new URL(request.url).origin) return true + } catch { + return false + } + + try { + return ( + originURL.origin === + new URL( + getCampaignInviteBaseURL({ + requestOrigin: origin, + forwardedHost: request.headers.get('x-forwarded-host') ?? request.headers.get('host'), + forwardedProto: request.headers.get('x-forwarded-proto'), + }), + ).origin + ) } catch { return false } diff --git a/tests/unit/sameOriginRequest.unit.spec.ts b/tests/unit/sameOriginRequest.unit.spec.ts new file mode 100644 index 00000000..17c3f99c --- /dev/null +++ b/tests/unit/sameOriginRequest.unit.spec.ts @@ -0,0 +1,120 @@ +// @vitest-environment node + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { isSameOriginRequest } from '@/utilities/sameOriginRequest' + +/** + * C146: behind the Cloudflare tunnel the request URL carries the internal + * origin (`http://localhost:3000`) while the browser sends the public one, so + * the guard must also accept an `Origin` matching the proxy-aware public base + * resolved with the established `getCampaignInviteBaseURL` policy. The legacy + * request-URL base stays pinned here (and in + * `campaignJsonMutationRoute.unit.spec.ts`) because it is what direct local + * access keeps matching. + */ +const tunnelPostRequest = (origin: string) => + new Request('http://localhost:3000/campanha/municipios/pledge-estimated-votes', { + method: 'POST', + headers: { + Origin: origin, + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'jorgesolla1313.com.br', + }, + }) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('isSameOriginRequest', () => { + it('approves a request without an Origin header', () => { + expect( + isSameOriginRequest( + new Request('https://jorgesolla1313.com.br/campanha/x', { method: 'POST' }), + ), + ).toBe(true) + }) + + it('approves an Origin matching the request URL via the legacy base alone', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('NEXT_PUBLIC_SITE_URL', '') + + expect( + isSameOriginRequest( + new Request('http://localhost:3100/campanha/x', { + method: 'POST', + headers: { Origin: 'http://localhost:3100' }, + }), + ), + ).toBe(true) + }) + + it('approves a production tunnel POST whose public Origin only matches NEXT_PUBLIC_SITE_URL', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://jorgesolla1313.com.br') + + expect(isSameOriginRequest(tunnelPostRequest('https://jorgesolla1313.com.br'))).toBe(true) + }) + + it('rejects a cross-origin POST even when the proxy announces the real host', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://jorgesolla1313.com.br') + + expect(isSameOriginRequest(tunnelPostRequest('https://evil.example'))).toBe(false) + }) + + it('fails closed in production without NEXT_PUBLIC_SITE_URL when the legacy base misses', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('NEXT_PUBLIC_SITE_URL', '') + + expect(isSameOriginRequest(tunnelPostRequest('https://jorgesolla1313.com.br'))).toBe(false) + }) + + it('rejects a chained forwarded host without crashing, letting the remaining bases decide', () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('NEXT_PUBLIC_SITE_URL', '') + + const request = new Request('http://localhost:3000/campanha/x', { + method: 'POST', + headers: { + Origin: 'https://tunnel.example', + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'localhost:3000,evil.example', + }, + }) + + expect(isSameOriginRequest(request)).toBe(false) + }) + + it('approves a dev request whose local Origin differs from the request URL port', () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('NEXT_PUBLIC_SITE_URL', '') + + const request = new Request('http://localhost:3000/campanha/x', { + method: 'POST', + headers: { + Origin: 'http://localhost:3100', + 'x-forwarded-proto': 'http', + 'x-forwarded-host': 'localhost:3100', + }, + }) + + expect(isSameOriginRequest(request)).toBe(true) + }) + + it.each(['null', 'not a url'])('rejects the unusable Origin %s', (origin) => { + expect(isSameOriginRequest(tunnelPostRequest(origin))).toBe(false) + }) + + it('keeps approving a direct TLS dev request via the request-URL base', () => { + expect( + isSameOriginRequest( + new Request('https://localhost:3443/campanha/x', { + method: 'POST', + headers: { Origin: 'https://localhost:3443' }, + }), + ), + ).toBe(true) + }) +})