# Plano — CSP Enforce + Cache de Borda (A4): coabitação segura

- **Data:** 2026-09-10
- **Stack:** Laravel 5.5.50 / PHP 7.2.34 (cPanel/AlmaLinux/Apache 2.4 + EasyApache4 + Multi-CDN)
- **Epico pai:** `public_infra_update-154b` (Segurança, `pl-d986`) — especificamente o ponto **SEC-1e** (`public_infra_update-c789`, enforce CSP)
- **Dependência de infra:** A4 (Multi-CDN / borda) — `plan/2026-08-09-infra-update.epic.md`
- **Decisão de abordagem:** **#1 — CSP por *hash* dos scripts/styles inline** (recomendada). Fallback obrigatório: **#2 — não cachear HTML na borda**.

---

## 1. Problema (por que isto existe)

Hoje o `CspNonce` (`app/Http/Middleware/CspNonce.php`) emite `Content-Security-Policy-Report-Only` com um **nonce por request**, injetado em todos os `<script>` inline. O `PageCache` (origem) e a **borda Multi-CDN** (A4) cacheiam o HTML com `Cache-Control: public, s-maxage=300` e sem `Set-Cookie`.

- Em **Report-Only** isso não explode (não bloqueia) — A4 funciona.
- Em **enforce** (`Content-Security-Policy`, SEC-1e), o objeto cacheado na borda carrega **um único nonce fixo** para todos os usuários → *static nonce vulnerability* clássico: qualquer um lê o nonce do HTML cacheado e injeta `<script nonce="…">`, derrotando o CSP (XSS).

Ou seja: **SEC-1e não pode ser virado para `enforce` enquanto o HTML de portal for cacheado na borda com nonce por request.** Esta é a únic@ trava real entre A4 e SEC-1e.

### Efeitos colaterais já presentes (descobertos na revisão)
- **Regressão de A2 (revalidação):** o nonce entra no corpo antes do `CacheControl` calcular o `ETag` (`md5` em `CacheControl.php:122`), logo o `ETag` muda a cada request → **304 nunca dispara** em páginas de portal. Os testes de `CacheControlTest` (ex.: `pagina_sem_csrf_continua_publica_com_etag_e_304`) **não executam o `CspNonce`**, então passam verdes embora a produção difira. O wiki `correcao-cache-control-borda-l1.md:173-186` ainda afirma `revalidation=1` — **defasado**.
- **Isolamento do admin quebrado:** admin está em `/cms` (`routes/basepro.php:32`), mas `CspNonce::shouldApply` só exclui `admin`/`prefecture` (`CspNonce.php:79-82`) → `/cms` recebe CSP hoje. Inócuo em Report-Only; furo no plano "admin por último" ao virar enforce.

---

## 2. Abordagem escolhida — #1: CSP por *hash* (recomendada)

Substituir o *nonce* por **`sha256`/`sha384`/`sha512` do conteúdo** de cada bloco `<script>`/`<style>` inline. O hash do conteúdo é **idêntico para todos os usuários** → a política CSP vira **estática e cacheável**, permanecendo segura (hash não requer sigilo, só fixa o conteúdo). Hosts externos já são allowlist estática → política 100% determinística → pode ser servida do cache de borda sem risco.

Pré-requisito obrigatório: o **corpo HTML deve ser determinístico por request**. Já é (CSRF é stripado em `CacheControl`; nonce é o único elemento por-request). Ao remover o nonce, o corpo fica idêntico → política cacheada bate.

### Sub-tarefas (ordem)
1. **Auditoria de inline handlers/attrs** (`onclick=`, `style="…"`, `onload=`, etc.) nos Blades de portal — definir se cabem em *hash* de elemento ou exigem `'unsafe-hashes'`/refactor. **Gate de decisão.**
2. **`CspNonce` → política por hash:** `applyHeadersAndNonce()` calcula `sha256` de cada `<script>`/`<style>` inline (parser já existe em `injectNonceIntoHtml`) e monta `script-src 'self' 'sha256-…' 'sha384-…' … hosts`; remove injeção de `nonce=`; `applyToCachedResponse()` passa a só recompor o header (corpo já determinístico).
3. **Tratar handlers/attrs inline** (gap de enforce, não só A4): refatorar `onclick=` para handlers externos OU habilitar `'unsafe-hashes'` na política (com caveat de suporte a CSP3) — fechar antes do enforce.
4. **Determinismo do corpo:** garantir que nenhum outro trecho varia por request (preconnect/speculationrules já são estáticos; confirmar RUM/Server-Timing fora do corpo).
5. **Transição segura:** `sd`/runbook de **purge da borda** (L1.3, depende de A4) no flip Report-Only → enforce; sem purge há janela servindo HTML sem enforcement (objetos cacheados ainda estariam em Report-Only).
6. **Corrigir `shouldApply`** para excluir `/cms` (além de `admin`/`prefecture`).
7. **Testes:** `PageCacheTest`/`CacheControlTest` cobrem política por hash + coerência HIT/MISS (corpo determinístico → `ETag` estável → 304 volta a funcionar em portal); eliminar o teste-cego de 304.
8. **Docs:** atualizar `plan/2026-09-10-security-epic.md` (SEC-1e sem `strict-dynamic`, por hash), `wiki/infra-update/csp-epico-seguranca.md` (estado enforce por hash) e `wiki/infra-update/correcao-cache-control-borda-l1.md` (revalidation corrigido).

### Fallback obrigatório — #2: não cachear HTML na borda
Se a auditoria (item 1) revelar muitos handlers/attrs inline inviáveis para hash/`'unsafe-hashes'`, adota-se #2:
- `CacheControl` deixa de anunciar `s-maxage`/`public` **no HTML de portal** (mantém `immutable` nos estáticos versionados) → borda só cacheia CSS/JS/fontes/WebP.
- Preserva nonce por request (seguro) e enforcement forte; aceita perda do cache de borda do HTML (TTFB passa a depender do FPC HIT de origem).
- Trade-off documentado: reverte parcialmente a meta A2 (`cache_hit>90%`) do L1.1.

### Não adotado — #3 (reescrever nonce na borda)
Edge worker / Varnish vmod / CF Worker injetando nonce por request no objeto cacheado. Mais poderoso, porém complexidade operacional fora do app; fica como opção futura.

---

## 3. Critérios de aceitação
- `curl -I /` em enforce mostra `Content-Security-Policy` **sem** `'nonce-'` e **com** `'sha256-…'` (ou, no fallback #2, `Cache-Control` do HTML sem `s-maxage`).
- Página de portal servida da borda (com `s-maxage`) **não** contém nonce reutilizável → não há vetor de *static nonce*.
- `ETag` estável entre requests idênticos → `304` funciona (revalida A2) em portal.
- `storage/logs/csp-violations.log` limpo (só 3rd-party mapeado) antes do enforce.
- `/cms` fora do CSP; forms (CSRF client-side) sem 419.
- Lighthouse `budget.json` (LCP/CLS) sem regressão.

## 4. Riscos
- **R1 (médio):** volume de `onclick`/`style=` inline nos 6 modelos → pode forçar fallback #2. Mitigação: auditoria primeiro.
- **R2 (baixo):** header CSP cresce com N hashes; aceitável (CSP não tem limite rígido).
- **R3 (médio):** janela de transição Report-Only→enforce na borda. Mitigação: purge obrigatório (L1.3).

## 5. Arquivos afetados
- `app/Http/Middleware/CspNonce.php` (política por hash; `shouldApply` `/cms`)
- `tests/Unit/{PageCacheTest,CacheControlTest}.php`
- `public/.htaccess` (só se fallback #2 exigir mudança de `Cache-Control` de HTML)
- `plan/2026-09-10-security-epic.md`, `wiki/infra-update/{csp-epico-seguranca,correcao-cache-control-borda-l1}.md`

## 6. Artefatos sd
- Pai: `SEC-1f` (feature/task) sob `pl-d986`, **bloqueando** `public_infra_update-c789` (SEC-1e) até concluído.
- Filhos: auditoria inline → implementação hash → handlers/attrs → determinismo → purge (L1.3) → `shouldApply /cms` → testes → docs.

## 7. Status de execução

### 7.1 Abordagem #1 (hash sha384) — implementada e DESCARTADA (2026-09-10)

Implementada e validada em canário: `CspNonce` calculava `sha384` dos blocos inline, sem
injetar nonce, política estática/cacheável, `applyToCachedResponse` no HIT, `'unsafe-hashes'`
em `script-src`/`style-src`, `shouldApply` excluindo `admin`/`prefecture`/`cms`, testes novos
(6). Suíte Unit verde (60 testes).

**Porém o canário real (`infra-update.devs.maxima.inf.br`) provou que #1 não chega ao enforce:**
- `unsafe-hashes` não é honrado de forma confiável por todos os browsers para `script-src-attr`/
  `style-src-attr` → handlers `onclick=` (barra de acessibilidade `aumenta()`/`diminue()`) e
  centenas de `style=""` continuavam blockeados.
- Libs vendor (fontawesome-all.js, vlibras-plugin.js) injetam `<script>`/`<style>` em runtime →
  indisponíveis para hash.
- Hashear centenas de `style=""` distintos é inviável; `unsafe-inline` é proibido em produção.

### 7.2 Abordagem #2 (nonce na origem + borda Report-Only) — ADOTADA (2026-09-10)

Decisão do usuário: voltar ao **nonce por request na origem**, com a **borda (A4) servindo
apenas `Report-Only`**. Implementado e validado em canário (`public_infra_update-app-1`):

- `CspNonce` gera `nonce = Str::random(32)` por request, injeta em `<script>`/`<style>` inline
  (sem `src=`), política `script-src 'self' 'nonce-{R}' <hosts> 'unsafe-hashes'; style-src …`
  (sem `sha384`, sem `unsafe-inline`). **Estado atual: `CspNonce` DESLIGADO via
  `config('csp.enabled')=false` (Opção B)** — nenhum header CSP é emitido, devtools
  limpo e sem log no servidor; a reativação (canário Report-Only) é só trocar
  `config/csp.php` para `true`. A coleta de violações também fica desligada quando
  reativado (sem `Reporting-Endpoints`/`report-uri`/`report-to`). Flip para
  `Content-Security-Policy` (enforce) em SEC-1e.
- **ETag/304 (A2) continua funcionando na origem**: o nonce é injetado no corpo *depois* do
  `CacheControl` emitir o ETag (ordem do grupo `web`: PageCache→CacheControl→…→CspNonce), então
  o ETag não inclui o nonce e fica estável; `applyToCachedResponse` re-injeta nonce fresco no HIT.
- `shouldApply` exclui `admin`/`prefecture`/`cms` (inalterado).
- `font-src 'self' data:` (libera `data:application/font-woff` do Ionicons).
- Testes reescritos (`tests/Unit/CspNonceTest.php`, 6 testes: nonce no header+corpo, nonce fresco
  por request, `<script src>` sem nonce, HIT injeta nonce, exclusões). Suíte Unit verde (60 testes).
- HTTP validado: `/` → 200, política `nonce-…`, 0 `sha384`, `unsafe-hashes` presente, nonce em 9
  pontos do corpo, ETag estável + `If-None-Match`→`304`; `/cms` → sem CSP.

### 7.3 Pendências para o enforce (SEC-1e)
- Revisar `storage/logs/csp-violations.log` no canário (fase Report-Only) e mapear 3rd-party.
- **Borda (A4) configurar downgrade enforce→Report-Only** no flip, para não servir nonce fixo em
  modo bloqueante (SEC-1f.5 / L1.3). Ainda dependente de A4.
- Decide-se depois se handlers/style-attr vão para refactor (addEventListener + classes) ou ficam
  cobertos por `unsafe-hashes` (onde o browser suporta) + `Report-Only` na borda.
