# ADR-020 — Segurança web moderna (defesa em profundidade)

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-20) |
| **Data** | 2026-10-14 |
| **Plano de origem** | `wiki/plan/01-analise-e-decisoes.md` |
| **Relacionadas** | ADR-024, ADR-008, ADR-010 |

## Contexto

O portal é alvo real (governo) e lida com dados de cidadãos. O legado tinha WAF quebrando o
editor (resolvido desligando o WAF, o que é ruim) e dependência de CDN. É preciso **defesa em
profundidade** com recursos modernos, sem depender de "desligar a proteção".

## Decisão

- **Headers obrigatórios:** `Content-Security-Policy` **estrita hash-based e cacheável** (sem
  `unsafe-inline`; nonce por request **só** em página marcada como não-cacheável — ADR-022) +
  **Trusted Types** no CMS; `Strict-Transport-Security` (preload);
  `X-Content-Type-Options: nosniff`; `Referrer-Policy: strict-origin-when-cross-origin`;
  `Permissions-Policy` mínima; `Cross-Origin-Opener-Policy: same-origin` /
  `Cross-Origin-Resource-Policy: same-origin`.
- **TLS 1.3** obrigatório e `URL::forceScheme('https')`.
- **Cookies:** `Secure; HttpOnly; SameSite=Lax` (prefixo `__Host-` na sessão),
  `session.use_strict_mode=1`; regeneração de id no login.
- **Auth:** `password_hash(..., PASSWORD_ARGON2ID)` + rehash; rate-limit no login; passkeys/WebAuthn
  como trilha opcional.
- **Entrada/saída:** FormRequest/Validation; prepared statements; `htmlspecialchars(...)`;
  `json_encode(..., JSON_THROW_ON_ERROR)`; upload validado por MIME real + tamanho.
- **PHP/ini hardening:** `expose_php=Off`, `display_errors=Off` em prod, `allow_url_include=Off`,
  `open_basedir`; `disable_functions` **só no pool web** (workers/Composer podem precisar de
  `proc_open`/`exec` — não desabilitar globalmente); **proibido** `rand()`/`mt_rand()`/`lcg_value()`
  para segredos (usar `Random\Randomizer`).
- **Cadeia de suprimentos:** lockfile commitado, `composer audit`/`npm audit` no CI, SBOM no build,
  segredos só por env/secret manager.
- **Modelo de ameaça leve** (STRIDE) por módulo com PII/arquivos.

## Justificativa (alternativas descartadas)

- **Desligar WAF** (lição do `tecvoto`) — descartada: resolve o sintoma e abre o flanco.
- **CSP permissiva (`unsafe-inline`)** — descartada: anula a proteção contra XSS.
- **`md5`/`sha1`/`rand` para segredos** — descartada.

## Consequências

**Positivas**
- Defesa em profundidade real; XSS mitigado por CSP + Trusted Types.

**Custos / negativas**
- CSP hash-based exige disciplina (zero inline; hashes gerados no build) e verificação no e2e.
- Headers/CSP precisam de verificação no e2e.

## Exemplo mínimo (comparativo)

```php
// ❌ Fraco
header('X-XSS-Protection: 1');
password_hash($pw, PASSWORD_DEFAULT); // bcrypt, custo baixo
$token = md5(random_bytes(8));

// ✅ Forte (hash-based, cacheável — sem nonce por request)
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-…' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; require-trusted-types-for 'script'");
password_hash($pw, PASSWORD_ARGON2ID);
$token = bin2hex(random_bytes(32)); // ou Random\Randomizer
```

## Verificação

- `curl -kI` retorna os headers; CSP sem `unsafe-inline`.
- `composer audit`/`npm audit` no CI; `pii_redact()` nos logs.

## Refinamento pós-v6-hardening (2026-10-14)

O épico de segurança do `v6-hardening` provou que **nonce por request dentro de HTML cacheado** é
incompatível com cache de borda/ETag (*static nonce vulnerability* + `304` quebrado), e que CSP por
*hash* no legado era inviável (handlers `onclick=`, `style=`, libs que injetam `<script>`).
Consequências para o v7 (**greenfield**):

- **Sem inline** (nenhum `<script>`/`<style>`/handler inline, zero 3rd-party) → CSP **estática e
  cacheável** (`'self'` + hashes), sem depender de nonce por request.
- **Nunca cachear HTML que embuta nonce por request**; se um nonce for inevitável, aquela página não
  é cacheada na borda (ver ADR-022).
- Headers de borda **estáticos** (CORP/COOP/HSTS/referrer/permissions/x-frame/nosniff) convivem com
  cache e `ETag` — usar sempre; CSP é o único que exige o cuidado acima.

## Referências

- Skill `web-security-review` (`.agents/skills/web-security-review/`); `wiki/specs/05-seguranca-lgpd.md`.

## Emenda 2026-10-20 — CSP operável e os dois terceiros do ADR-002

O "zero 3rd-party" acima significa **zero CDN/fonte/script de UI**. As duas exceções já
decididas no ADR-002 continuam válidas e precisam estar **explicitamente** na CSP do portal:

- **VLibras** (`vlibras.gov.br`, `www.vlibras.gov.br`): carregado **sob demanda** no clique;
  `script-src`, `frame-src`, `connect-src`, `img-src`, `style-src`, `font-src` e `media-src`.
- **Google Analytics** (opt-in, só com ID no painel): `script-src` `googletagmanager.com`;
  `connect-src`/`img-src` `*.google-analytics.com`/`googletagmanager.com`.

O CMS não recebe esses hosts (o editor não os usa). **Não há `report-uri`/`report-to`** por
decisão: um endpoint de relatório é superfície nova (corpo, throttle, retenção) e as violações
são cobertas por e2e (`securitypolicyviolation`) e `tools/security-check.sh`. Se o relatório for
adotado, exige endpoint same-origin com throttle, corpo ≤16 KB e log rotacionado (mitigação de
F-09 do `_INFRA`).
