# 05 — Segurança: headers, CSP, cookies, CSRF e HTTPS

> **Fontes:** `public/.htaccess`, `app/Http/Middleware/CspNonce.php`,
> `app/Http/Middleware/CacheControl.php`, `config/session.php`,
> `csp-epico-seguranca.md`, `csp-ativar-mapear-scripts.md`, `AGENTS.md`.
> **Cópias literais:** `arquivos/public.htaccess`, `arquivos/middleware/CspNonce.php`,
> `arquivos/config/session.php`, `arquivos/config/csp.php`.

---

## 1. Estado atual (importante)

| Controle | Estado no snapshot |
|---|---|
| Headers HSTS/XFO/XCTO/Referrer/Permissions | **Ativos** via `.htaccess` |
| `Cross-Origin-Resource-Policy` / `Cross-Origin-Opener-Policy` | **Ativos** via `.htaccess` |
| Cookies `Secure` + `SameSite=Lax` + `HttpOnly` | **Ativos** via `config/session.php` + `.env` |
| Redirect HTTP→HTTPS | **Ativo** via `.htaccess` (aware de `X-Forwarded-Proto`) |
| Bloqueio de artefatos sensíveis | **Ativo** via `mod_rewrite` (`THE_REQUEST`) |
| CSP por nonce | **Implementado, mas DESLIGADO** (`config('csp.enabled') = false`) |
| Coleta de violações CSP | **Desligada** (sem `report-uri`/`report-to`/`Reporting-Endpoints`) |
| SRI | **Ativo apenas em `chart.js` self-hosted** |

> ⚠️ O `README.md`/`AGENTS.md` falam em “CSP Report-Only + Reporting-Endpoints”
> como estado atual. No snapshot, `config/csp.php` retorna `enabled => false` e o
> `CspNonce` não emite `Reporting-Endpoints`. O código está pronto, porém inativo.

---

## 2. Headers de segurança (`.htaccess`)

### 2.1 Lista e finalidade

```apache
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "0"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=()"
Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Header always unset X-Powered-By
Header always set Server "MaximaTecnologia"
ServerSignature Off
Header always set X-Hardening-Version "2026-08-maxima"
```

| Header | Proteção |
|---|---|
| `X-Frame-Options: SAMEORIGIN` | Clickjacking (legado; CSP `frame-ancestors` complementa) |
| `X-Content-Type-Options: nosniff` | Impede MIME sniffing |
| `X-XSS-Protection: 0` | Recomendação atual (o filtro legado pode introduzir problemas; CSP é o controle real) |
| `Referrer-Policy` | Não vaza URL completa para terceiros |
| `Permissions-Policy` | Desliga geolocalização/câmera/microfone/pagamento/USB |
| `Strict-Transport-Security` | Força HTTPS por 2 anos, subdomínios, preload |
| `X-Powered-By` removido | Não expõe PHP |
| `Server: MaximaTecnologia` | Reduz fingerprint do Apache |
| `X-Hardening-Version` | Canário de deploy |

### 2.2 Regra crítica do Apache

> **Nunca** use `<IfModule mod_headers.c>` neste servidor. A tag causa **HTTP 500**.
> As diretivas `Header` devem ficar “cruas”.

### 2.3 Isolamento de origem

```apache
Header always set Cross-Origin-Resource-Policy "same-origin"
Header always set Cross-Origin-Opener-Policy "same-origin"
```

- `CORP: same-origin` impede que outros sites leiam recursos do portal via
  `<img>`, `<script>`, `fetch` cross-origin.
- `COOP: same-origin` isola o browsing context (mitiga ataques de janela
  cruzada/Spectre-like).

---

## 3. CSP por nonce (`CspNonce`)

### 3.1 Arquitetura

- **Config:** `config/csp.php` → `'enabled' => false`.
- **Middleware:** `app/Http/Middleware/CspNonce.php`.
- **Ordem:** no grupo `web`, **depois** de `CacheControl`, `HtmlMinify` e
  `PictureTransform`.
- **Escopo:** GET do portal público; exclui `admin`, `prefecture`, `cms`.
- **Nonce:** `Str::random(32)` por request.
- **Injeção:** reescreve a tag de abertura de `<script>` inline (sem `src=`) e
  `<style>` inline, adicionando `nonce` **quando ausente**. O código atual **não
  substitui** um `nonce` já existente — ver §3.2.1.
- **Header:** `Content-Security-Policy-Report-Only`.
- **FPC:** no HIT, `PageCache` chama `CspNonce::applyToCachedResponse()`. A
  intenção é reinjetar um nonce **fresco**; porém o injetor ignora tags que já
  têm `nonce=`. Como o corpo gravado no MISS já contém o nonce (com CSP ligado),
  o HIT **não** o substitui. Ver §3.2.1.

### 3.2 Por que nonce por request + borda Report-Only

> **Correção (revisão de 2026-10-20):** a afirmação de que o nonce é injetado
> *depois* do `CacheControl` calcular o ETag **não vale para o snapshot**. O grupo
> `web` executa o outbound em ordem inversa: `CspNonce` está depois de
> `CacheControl` no array, logo roda **antes** dele na volta. Com CSP ligado, o
> nonce entra no corpo antes do `md5()` do ETag, o ETag muda a cada request e o
> `304` deixa de funcionar. Com CSP desligado (estado atual), o corpo não é
> mutado e o ETag é estável. Ver §3.2.1 e `10-achados-e-patches.md` F-01.

A borda (A4 / Multi-CDN) serve apenas **Report-Only** (downgrade enforce→Report-Only
no flip SEC-1e), evitando a *static nonce vulnerability*: se a borda servisse um
nonce fixo em modo **enforce**, um atacante poderia reutilizá-lo. Em Report-Only,
não há bloqueio, então o nonce fixo não é uma vulnerabilidade explorável.

### 3.2.1 Estado real da ordem CSP × ETag × FPC

| Cenário | Corpo | ETag | 304 | FPC HIT |
|---|---|---|---|---|
| CSP **desligado** (atual) | não mutado | estável | funciona | corpo sem nonce; OK |
| CSP **ligado** | nonce injetado antes do ETag | muda por request | **quebra** | corpo do MISS já tem nonce; `applyToCachedResponse` não substitui → header/body divergem |

Além disso, os dois partials que já trazem `nonce="{{ $cspNonce ?? '' }}"` renderizam
`nonce=""` quando a variável não é compartilhada. O `CspNonce` atual **não** faz
`View::share('cspNonce', ...)`; como o injetor ignora qualquer tag que já tenha
`nonce=`, esses scripts ficam com nonce vazio. É um bloqueador de enforce, não do
estado atual (CSP desligado).

**Ordem correta para CSP + ETag + FPC (patch recomendado, não aplicado):**

```php
'web' => [
    \App\Http\Middleware\ServerTiming::class,
    \App\Http\Middleware\PageCache::class,
    \App\Http\Middleware\CspNonce::class,   // antes de CacheControl no array
    \App\Http\Middleware\CacheControl::class, // outbound: ETag antes do nonce
    // ...
],
```

E o injetor deve **substituir** um `nonce` existente (ou o `PageCache` deve
gravar o corpo antes da injeção e reaplicar no HIT). A alternativa conservadora é
manter CSP desligado e não cachear HTML na borda enquanto o enforce não for
resolvido (fallback #2 do plano SEC-1f).

### 3.3 Política atual (quando ligada)

```php
$hosts = "https://www.gstatic.com https://www.google.com https://vlibras.gov.br"
    . " https://cdn.jsdelivr.net https://static.addtoany.com https://www.addtoany.com"
    . " https://www.facebook.com https://connect.facebook.net https://www.youtube.com";

return "default-src 'self';"
    . "script-src 'self' 'nonce-" . $nonce . "' " . $hosts . " 'unsafe-hashes';"
    . "style-src 'self' 'nonce-" . $nonce . "' 'unsafe-hashes';"
    . "img-src 'self' data: https:;"
    . "font-src 'self' data:;"
    . "connect-src 'self';"
    . "frame-src 'self' https://www.youtube.com https://vlibras.gov.br;"
    . "object-src 'none';"
    . "base-uri 'none';"
    . "frame-ancestors 'self';"
    . "upgrade-insecure-requests;";
```

Decisões:

- **Sem `'strict-dynamic'`:** sob enforce, `'strict-dynamic'` desativa a
  host-allowlist e bloquearia todos os `<script src>` externos (jQuery/Bootstrap,
  VLibras, reCAPTCHA, charts). A política usa host-allowlist + nonce para inline.
- **`'unsafe-hashes'`:** libera handlers inline (`onclick=`) e `style=""` **somente**
  se o hash do conteúdo estiver na política. Sozinho, não libera; ver §3.6.
- **`object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'self'`,
  `upgrade-insecure-requests`:** endurecimento padrão.
- **Sem `unsafe-inline`:** veto do projeto para produção.

### 3.4 Como ligar (3 níveis)

#### Nível 0 — DESLIGADO (atual)

```php
// config/csp.php
return ['enabled' => false];
```

Nenhum header CSP é emitido. Devtools limpo, sem log, borda cacheia normalmente.

#### Nível 1 — Canário com COLETA (para listar o que falta)

1. `config/csp.php` → `'enabled' => true`.
2. Em `CspNonce::applyHeadersAndNonce()`, recompor o endpoint de relatório (hoje
   removido):

```php
$endpoint = $request->getSchemeAndHttpHost() . '/csp-report';
$response->headers->set(
    'Content-Security-Policy-Report-Only',
    $this->policy($nonce) . " report-uri " . $endpoint . "; report-to csp;"
);
$response->headers->set('Reporting-Endpoints', 'csp="' . $endpoint . '"');
```

3. `php artisan cache:page:clear` e reload do FPM.
4. Navegar home, notícias, licitações, legislação, contato. Cada violação vira um
   POST em `storage/logs/csp-violations.log`.

#### Nível 2 — ENFORCE (SEC-1e)

Trocar o nome do header de `Content-Security-Policy-Report-Only` para
`Content-Security-Policy`. Só depois de:

- zero violação legítima no log do Nível 1;
- borda A4 configurada para servir **Report-Only** (downgrade enforce→Report-Only);
- resolução do conteúdo injetado em runtime por libs vendor (§3.6).

### 3.5 Coletor de violações

`app/Http/Controllers/Portal/CspReportController.php`:

- rota `POST /csp-report` (`throttle:120,1`), fora do grupo de manutenção;
- `csp-report` em `VerifyCsrfToken::$except`;
- grava `storage/logs/csp-violations.log` (1 JSON por linha, com
  `ip`/`user_agent`/`referer`/`report`);
- responde `204`.

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

### 3.6 Como listar scripts que precisam de ajuste

#### Via relatórios do browser (Nível 1 ligado)

```bash
# violações por diretiva
grep -oE '"violated-directive":"[^"]+"' storage/logs/csp-violations.log \
  | sort | uniq -c | sort -rn

# violações por arquivo de origem
grep -oE '"source-file":"[^"]+"' storage/logs/csp-violations.log \
  | sort | uniq -c | sort -rn

# amostras de script/style inline violados
grep -oE '"script-sample":"[^"]*"' storage/logs/csp-violations.log \
  | sort | uniq -c | sort -rn
```

O `script-sample` é o conteúdo exato do bloco/atributo violado. O hash a
adicionar é `sha256` desse conteúdo.

#### Via devtools

Cada aviso termina com:

```
Consider using a hash ('sha256-XXXX') or a nonce.  arquivo:linha:col
```

#### Varredura estática

```bash
grep -rnE "<script(?![^>]*\bsrc=)" resources/views --include=*.blade.php
grep -rnoE "\bon[a-z]+=" resources/views --include=*.blade.php
grep -rnE "style=" resources/views --include=*.blade.php
```

> A varredura estática não captura conteúdo injetado em runtime por libs vendor
> (ex.: `fontawesome-all.js`, `vlibras-plugin.js`). Isso só aparece no Nível 1 ou
> no devtools.

### 3.7 Categorias de problema e correção

| Categoria | Correção |
|---|---|
| `<script>`/`<style>` inline no HTML | Já resolvido pelo `CspNonce` (nonce automático). |
| Handlers `onclick=`/`style=""` estáticos | (a) hash `sha256` + `'unsafe-hashes'`; ou (b) refatorar para `addEventListener`/classes CSS. |
| `style=""` dinâmico (ex.: `background-image: url(...)`) | Não dá para hashear; mover para classe CSS ou setar via JS a partir de `<style>` com nonce. |
| Injeção runtime por libs vendor | **Bloqueador real do enforce**: FA/VLibras criam `<style>`/`<script>` fora do HTML inicial; nonce/hash não alcança. Resolver com versão que não injeta inline ou aceitar impacto. |

### 3.8 Critério para enforce (SEC-1e)

1. Log do Nível 1 com **zero** recurso legítimo bloqueado.
2. Borda A4 configurada para downgrade enforce→Report-Only.
3. Conteúdo vendor runtime resolvido ou aceito explicitamente.
4. `php artisan cache:page:clear` + reload FPM + validação de headers em
   home/notícias/contato/licitações/legislação, e `304` estável.

### 3.9 Validação

```bash
# header CSP presente? (quando ligado)
curl -k -sI https://SEU_HOST/ | grep -i "content-security-policy"

# header e nonce do corpo coincidem
BODY=$(curl -s https://SEU_HOST/)
NONCE=$(printf '%s' "$BODY" | grep -o 'nonce="[^"]*"' | head -1 | cut -d'"' -f2)
printf '%s' "$BODY" | grep -q "nonce=\"$NONCE\"" && echo "nonce coerente"

# 304 estável (com CSP desligado; com CSP ligado, ver §3.2.1)
ETAG=$(curl -sI https://SEU_HOST/ | grep -i etag | tr -d '\r' | awk '{print $2}')
curl -sI -H "If-None-Match: $ETAG" https://SEU_HOST/ | grep -i '^HTTP/'
```

---

## 4. Cookies e sessão

### 4.1 `config/session.php`

```php
'secure' => env('SESSION_SECURE_COOKIE', true),
'http_only' => true,
'same_site' => env('SESSION_SAME_SITE', 'lax'),
```

`.env`:

```dotenv
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
```

- `Secure`: cookie só trafega em HTTPS.
- `HttpOnly`: JS não acessa o cookie de sessão.
- `SameSite=Lax`: mitiga CSRF cross-site; mantém navegação top-level.

> Em dev HTTP, o cookie `Secure` deixa de ser setado — comportamento esperado.

### 4.2 `SameSite` via `.htaccess`

O `.htaccess` também adiciona `SameSite=Lax` a `Set-Cookie` via `Header edit`
(bloco H7). O `Secure` **não** é definido ali; é responsabilidade do
`config/session.php`/`.env`.

### 4.3 Verificação

```bash
curl -s -D - -o /dev/null https://SEU_HOST/cms | grep -i '^set-cookie'
# esperado: ...; secure; httponly; samesite=lax
```

---

## 5. CSRF e cache

### 5.1 O risco

Toda página do portal embute `<meta name="csrf-token">` e hidden `_token`.
Se o HTML for cacheado publicamente com o token de uma sessão, outro usuário pode
receber esse token e o submit falha com **419 Page Expired** (ou pior, se o token
fosse válido para outra sessão).

### 5.2 A solução no snapshot

1. `CacheControl` remove o **valor** de todos os tokens CSRF do HTML (regex
   ancorado em 40 caracteres `[A-Za-z0-9+]`).
2. O HTML fica estável (ETag constante) e seguro para cache compartilhado.
3. `resources/views/portal/layouts/csrf-inject.blade.php` obtém o token em
   client-side:
   - cookie `portal_csrf` (httpOnly=false), ou
   - `GET /csrf-token` (`CsrfTokenController@show`).
4. O JS injeta `X-CSRF-TOKEN` em `jQuery.ajaxSend` e `window.fetch`, e preenche
   `_token` no `submit` de `form[POST]`.

### 5.3 Regras

- Não remover `Set-Cookie` de páginas de formulário **antes** do desacoplamento.
- Não editar os ~60 blades do portal para o token; a remoção central é mais
  segura (sem arquivo esquecido vazando token).
- `/csrf-token` fica **fora** do cache (`excludeNames = ['csrf']`) e mantém o
  cookie de sessão.

### 5.4 Validação

```bash
# HTML público não deve conter token de 40 chars no meta
curl -s https://SEU_HOST/ | grep -o 'name="csrf-token" content="[^"]*"'

# endpoint devolve token e mantém cookie
curl -s -D - https://SEU_HOST/csrf-token | grep -iE 'set-cookie|cache-control|token'

# submit de formulário deve funcionar sem 419
# (testar contato/ouvidoria/newsletter no canário)
```

---

## 6. HTTPS e HSTS

- Redirect 301 HTTP→HTTPS no `.htaccess`, com `X-Forwarded-Proto` para não criar
  loop atrás de proxy/CDN.
- `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`.
- TLS 1.3/HTTP/2 são guardrails de infraestrutura (não configurados pelo `.htaccess`).

### Validação

```bash
curl -sI http://SEU_HOST/ | grep -iE '^HTTP|^location'
# esperado: 301 + Location: https://...

curl -sI https://SEU_HOST/ | grep -i strict-transport-security
# esperado: max-age=63072000; includeSubDomains; preload
```

---

## 7. SRI (Subresource Integrity)

- Aplicado apenas a `chart.js` **self-hosted** e version-pinned
  (`integrity="sha384-…"` + `crossorigin="anonymous"`).
- **Não** aplicar SRI em loaders externos voláteis (`vlibras-plugin.js`,
  `recaptcha/api.js`, `gstatic charts/loader.js`): o fornecedor atualiza e o SRI
  quebraria o recurso. Esses ficam cobertos pela host-allowlist do CSP.
- Se SRI for mandatório para eles, o caminho é self-host de cópias versionadas.

---

## 8. Bloqueio de artefatos sensíveis

O `.htaccess` bloqueia via `THE_REQUEST` (403):

- `.env`, `.git`, `.user.ini`;
- `composer.json`, `composer.lock`;
- `package.json`, `package-lock.json`;
- `phpunit.xml`, `web.config`, `error_log`.

```bash
for p in .env .git/config .user.ini composer.json composer.lock \
         package.json phpunit.xml web.config error_log; do
  printf '%-20s ' "$p"
  curl -s -o /dev/null -w '%{http_code}\n' "https://SEU_HOST/$p"
done
# esperado: 403 (ou 404, nunca 200)
```

---

## 9. Matriz de riscos e não-objetivos

| Risco | Mitigação atual | Pendência |
|---|---|---|
| XSS refletido/armazenado | CSP (quando enforce), escaping Blade, headers | Enforce CSP bloqueado por libs runtime |
| Clickjacking | XFO + `frame-ancestors 'self'` | — |
| CSRF | Token + SameSite + strip/rebuild client-side | Validar forms no canário |
| Sequestro de sessão | `Secure` + `HttpOnly` + `SameSite=Lax` | — |
| Vazamento de artefatos | Blocklist `.htaccess` | Revisar novos arquivos sensíveis |
| MIME sniffing | `nosniff` + MIME correto | — |
| Cache poisoning | `Vary: Accept-Encoding`, sem UA, chave normalizada | Validar borda A4 |
| Nonce fixo na borda | Borda só Report-Only | Configurar A4 antes do enforce |
| APCu/OPcache desatualizado | Reset no deploy | Script atual não automatiza |
| Rebuild EA4 remove APCu | Revalidar após update | Processo manual |

### Não-objetivos

- Não substituir WAF/CDN.
- Não implementar autenticação/multi-fator neste escopo.
- Não aplicar CSP no admin/prefeitura (isolamento deliberado).
- Não usar `unsafe-inline` em produção.
