# CSP Estrito + Endurecimento de Segurança (Épico SEC-1)

- **Branch:** `ticket/#23410-v6`
- **Stack:** Laravel 5.5.50 / PHP 7.2.34 (cPanel/AlmaLinux/Apache+php-fpm)
- **Épico (sd):** `public_infra_update-154b` · **Plano (sd):** `pl-d986`
- **Backlog:** `plan/2026-09-10-security-epic.md`
- **Status:** `CspNonce` implementado mas **DESLIGADO** via `config('csp.enabled') = false` (ver `config/csp.php`). Nenhum header CSP é emitido hoje — devtools limpo, sem log no servidor, borda cacheia normalmente. Reativar o canário (Report-Only) é só trocar `config/csp.php` para `true`; enforce (SEC-1e) segue adiado.

---

## 1. Objetivo

Endurecer a segurança do portal (camada de origem) com 5 frentes, sem quebrar o
site: CSP estrito com *nonce* por request + coletor de violações, `Cross-Origin-Resource-Policy`/`Cross-Origin-Opener-Policy`, cookie de sessão `Secure`+`SameSite`, `frame-ancestors` e redirect 301 HTTP→HTTPS. A abordagem é **canário**: primeiro em `Content-Security-Policy-Report-Only` + `Reporting-Endpoints`, acumulando violações reais de produção, e só depois (SEC-1e) trocar para `Content-Security-Policy` bloqueante.

## 2. Itens entregues

| Item | O que | Estado |
|------|-------|--------|
| SEC-1a | Coletor `POST /csp-report` (`CspReportController`) + `Reporting-Endpoints` | ✅ |
| SEC-1b | Middleware `CspNonce` (política por **nonce por request** na origem, borda serve *Report-Only* — abordagem #2 do SEC-1f) | ✅ |
| SEC-1c | Migração portal-first dos `<script>` inline → *nonce* (centralizada) | ✅ |
| SEC-1d | SRI (`sha384`) nos scripts version-pinned de `chart.js` (self-hosted) | ✅ |
| SEC-1e | **Enforce** (trocar Report-Only → bloqueante) | ⏸ adiado |
| SEC-2  | `Cross-Origin-Resource-Policy` + `Cross-Origin-Opener-Policy: same-origin` | ✅ |
| SEC-3  | Cookie de sessão `secure` + `same_site=lax` | ✅ |
| SEC-4  | `frame-ancestors 'self'` (na política; vale no enforce) | ✅ (na política) |
| SEC-5  | Redirect 301 HTTP→HTTPS (aware `X-Forwarded-Proto`) | ✅ |

## 3. Como o canário funciona

1. `CspNonce` (GET de portal, exceto `admin`/`prefecture`/`cms`) gera um **nonce por
   request** (`Str::random(32)`) e o injeta nos blocos `<script>`/`<style>` inline do
   corpo; emite a política `Content-Security-Policy-Report-Only` com `'nonce-{R}'` +
   `'unsafe-hashes'` (libera handlers `onclick=`/`style=""`) + allowlist de host. O
   corpo é mutado (ganha o nonce) **depois** do `CacheControl` emitir o ETag, então o
   ETag fica estável e o `304` continua funcionando na origem. A **borda (A4) serve
   apenas Report-Only** (downgrade enforce→Report-Only no flip SEC-1e), evitando o
   *static nonce vulnerability* — ver SEC-1f (abordagem #2).
   `Reporting-Endpoints: csp="https://<host>/csp-report"`. **Coleta de violações está
   DESLIGADA** (sem `report-uri`/`report-to`/`Reporting-Endpoints`): o browser não envia
   relatórios e o servidor não acumula log — o canário é silencioso. Para reativar o
   monitoramento, basta recompor `Reporting-Endpoints`+`report-uri` (ver `CspReportController`).
2. O browser, ao encontrar algo que violaria a política, POSTa um relatório CSP
   para `/csp-report`.
3. `CspReportController` grava `storage/logs/csp-violations.log` (1 JSON por linha,
   com `ip`/`user_agent`/`referer`/`report`) e responde `204`.

```json
{"time":"...","ip":"...","user_agent":"...","referer":null,
 "report":{"csp-report":{"document-uri":"...","violated-directive":"script-src","blocked-uri":"..."}}}
```

> O canário não bloqueia nada: os scripts continuam executando. Ele apenas
> **registra** o que violaria a política, para revisão antes do enforce.

## 4. Arquivos alterados

**Novos**
- `app/Http/Middleware/CspNonce.php` — política por **nonce por request** na origem (injetado nos `<script>`/`<style>` inline), `'unsafe-hashes'`, `applyToCachedResponse()` para o HIT do FPC (re-injeta nonce fresco). `shouldApply` exclui `admin`/`prefecture`/`cms`. **Todo o middleware é guardado por `config('csp.enabled')`** — hoje `false`, então nada é emitido. A borda (A4) serviria Report-Only quando reativado.
- `config/csp.php` — chave `enabled` (default `false`). Liga/desliga o `CspNonce` (handle + HIT do FPC).
- `app/Http/Controllers/Portal/CspReportController.php` — coletor `POST /csp-report` (grava `csp-violations.log`, `204`).
- `plan/2026-09-10-security-epic.md` — backlog do épico.

**Alterados**
- `app/Http/Kernel.php` — registra `CspNonce` no grupo `web` (após `PictureTransform`).
- `app/Http/Middleware/PageCache.php` — no HIT, reaplica `CspNonce::applyToCachedResponse()` (o FPC guarda só o corpo; sem isto o HIT serviria o *nonce* injetado no MISS mas sem o cabeçalho CSP).
- `routes/web.php` — `POST /csp-report` (`throttle:120,1`), fora do grupo de manutenção.
- `app/Http/Middleware/VerifyCsrfToken.php` — `csp-report` em `$except`.
- `config/session.php` — `secure => env('SESSION_SECURE_COOKIE', true)`, `same_site => env('SESSION_SAME_SITE', 'lax')`.
- `.env` — `SESSION_SECURE_COOKIE=true`, `SESSION_SAME_SITE=lax`.
- `public/.htaccess` — H5 com `Cross-Origin-Resource-Policy`/`Cross-Origin-Opener-Policy` (SEC-2); rewrite 301 HTTP→HTTPS aware `X-Forwarded-Proto` (SEC-5). `Reporting-Endpoints` foi movido para o middleware (Apache deste ambiente não expande `%{VAR}` em `Header`).
- `resources/views/portal/layouts/partials/pwa.blade.php` e `preconnect.blade.php` — *nonce* nos `<script>` (redundante com a injeção central, mantido como defesa em profundidade).
- 14 tags `<script>` de `chart.js` (admin `dashboard.blade.php` + 6× `modelo*/dynamics-forms/filled-questionnaire/show.blade.php`) — `integrity="sha384-…"` + `crossorigin="anonymous"` (SEC-1d).

## 5. Decisões de implementação

- **Injeção central de *nonce* (SEC-1c):** em vez de editar dezenas de Blades
  duplicados entre os 6 modelos, o `CspNonce` reescreve a *tag de abertura* de
  cada `<script>` inline (sem `src=`), adicionindo/substituindo o `nonce`. Cobre
  todo o portal com 1 arquivo e mantém `header == corpo` no HIT do `PageCache`
  (nonce por request, mesmo nas páginas cacheadas).
- **Sem `'strict-dynamic'`:** sob *enforce*, `'strict-dynamic'` desativa a
  *host-allowlist* e bloquearia todos os `<script src>` externos (jQuery/Bootstrap,
  VLibras, reCAPTCHA, charts). Por isso a política usa *host-allowlist* + *nonce*
  para inline. Hosts externos permitidos: `gstatic`, `google`, `vlibras.gov.br`,
  `jsdelivr`, `addtoany` (static/www), `facebook` (www/connect), `youtube`.
- **SRI só no `chart.js` self-hosted (SEC-1d):** o `chart.js` é servido do próprio
  origem (`assets/plugins/chart.js/…`), version-pinned e imutável → SRI seguro.
  Os 3 *loaders* externos reais (`vlibras-plugin.js`, `recaptcha/api.js`,
  `gstatic charts/loader.js`) são controlados pelo fornecedor e atualizados
  frequentemente; SRI neles quebraria o recurso no próximo update do provedor.
  Eles ficam cobertos pela *host-allowlist*. Se SRI for mandatório para eles, o
  caminho é *self-host* de cópias version-pinned.
- **Cookie `Secure` (SEC-3):** `SESSION_SECURE_COOKIE` antes não existia no `.env`
  (default `false`); agora `true`. Em dev HTTP o cookie para de ser setado — esperado.

## 6. Validação (feita em container)

- `php -l` limpo em todos os PHP alterados; `route:list` mostra `csp-report` POST `web,throttle:120,1`; `config('session')` → `secure=true`, `same_site=lax`.
- `GET /` (HTTPS): `200` + `Content-Security-Policy-Report-Only` com *nonce* + `Reporting-Endpoints`. **MISS e HIT do FPC** com `header == corpo` (nonce coerente) e `0` `<script>` inline sem *nonce* (home e `/contato`).
- `POST /csp-report`: `204` e linha gravada em `csp-violations.log`.
- `HTTP → 301 → HTTPS`.
- `Cross-Origin-Resource-Policy: same-origin` + `Cross-Origin-Opener-Policy: same-origin` presentes.
- Cookie `prefeitura_v5_session` com `secure; httponly; samesite=lax`.
- `admin`/`prefecture` permanecem sem CSP (isolamento por `shouldApply`).

## 7. Pendência: SEC-1e (enforce) — como proceder

Quando os logs acumulados estiverem estáveis:

1. Revisar `storage/logs/csp-violations.log` agrupando por `violated-directive` /
   `blocked-uri` para confirmar que não há recurso legítimo sendo barrado
   (handlers `onclick=`, estilos inline, `connect-src` para analytics/tag managers,
   hosts externos ausentes na allowlist).
2. Ajustar a política em `CspNonce::policy()` se necessário (ex.: `connect-src`,
   hosts, `style-src 'unsafe-inline'`→*nonce*).
3. Trocar `Content-Security-Policy-Report-Only` → `Content-Security-Policy` no
   `CspNonce::applyHeadersAndNonce()`.
4. Revalidar em staging antes de produção.

> ✅ `report-uri` (legado) já adicionado ao lado de `report-to` em
> `CspNonce::applyHeadersAndNonce()` para capturar violações em browsers sem
> suporte à Reporting API.

## 8. Referências

- Backlog: `plan/2026-09-10-security-epic.md`
- Coletor: `app/Http/Controllers/Portal/CspReportController.php`
- Middleware: `app/Http/Middleware/CspNonce.php`
- Logs: `storage/logs/csp-violations.log`
