# Vulnerabilidades de Injeção de SQL — Achados da Investigação

> **Data:** 2026-10-21
> **Escopo:** verificação durante o mapeamento dos módulos (`dossie_modules/`)
> **Escopo real:** portal público (rotas sem autenticação) da **v5** e da **v6**
> **Severidade:** **CRÍTICA** — leitura arbitrária do banco, sem autenticação

---

## 1. Resumo

Durante a investigação foram confirmados **6 pontos de injeção de SQL**
**exploráveis por visitante anônimo** do portal, nas duas versões
(`v5/` e `v6/`). Todos resultam de interpolação direta de parâmetro de
requisição em SQL string, sem binding.

**Nenhum desses pontos exige login.** Todos estão em controllers `Portal\`
— a face pública do sistema.

| # | Endpoint (público) | Parâmetro | Forma da injeção | v5 | v6 |
|---|---|---|---|---|---|
| 1 | `GET /legislacao/{slug}` | `ano` | `{$_GET['ano']}` em `DB::select` | OK | OK |
| 2 | `GET /publicacoes/{slug}` | `ano` | `{$_GET['ano']}` em `DB::select` | OK | OK |
| 3 | `GET /atas-e-pontos/{slug}` | `ano` | `{$_GET['ano']}` em `DB::select` | n/a | OK |
| 4 | `GET /comite-de-investimentos/{slug}` | `ano` | `{$_GET['ano']}` em `DB::select` | n/a | OK |
| 5 | `GET /requerimento-de-diarias` | `req_name` | concatenação em `whereRaw` | OK | OK |
| 6 | `GET /requerimento-de-diarias` | `num_diaria` | concatenação em `whereRaw` | OK | OK |
| 7 | `GET /requerimento-de-diarias` | `cpf` | concatenação em `whereRaw` | OK | OK |
| 8 | `POST /legislacao/filtro` | `termo` | concatenação em `whereRaw` | — | OK |

> `n/a` = a rota não existe na v5 (módulo `atas-e-pontos` é novo na v6).

---

## 2. Prova de explorabilidade

Todas as verificações foram feitas contra as instâncias **locais** de
desenvolvimento, com payloads **não destrutivos** (apenas erro de sintaxe ou
leitura), sem alteração de dados.

### 2.1 Interpolação de `$_GET['ano']`

`v6/app/Http/Controllers/Portal/LegislationController.php:56`:

```php
$meses = DB::select("select extract(month from publication_date) as mes from contents
    WHERE category_contents_id = {$category->id}
      and extract(year from publication_date) = {$_GET['ano']}
      and publication_date <= NOW()
    GROUP BY mes order by extract(month from publication_date) asc;");
```

Note que **o controller valida `$request->ano` logo abaixo** (`whereYear`
usa binding), mas a consulta de meses usa `$_GET['ano']` **crudo** — a
validação não protege a query.

Payload (aspas simples) — resposta real do servidor:

```
GET https://localhost:35979/legislacao/constituicao-federal?ano=%27
HTTP 500
SQLSTATE[42601]: Syntax error: 7 ERROR:  unterminated quoted string at or near
"' and publication_date <= NOW()    GROUP BY mes ..."
```

Payload booleano — altera o conjunto retornado (a página passa de ~85 KB
para ~755 KB, porque a condição `2024 OR 1=1` passa a valer para todos os
anos e meses):

```
GET https://localhost:35979/legislacao/constituicao-federal?ano=2024%20OR%201%3D1
HTTP 500 — SQLSTATE[22P02]: invalid input syntax for type double precision: "2024 OR 1=1"
```

> O erro de *cast* prova que o valor chegou ao SQL **sem escaping** e foi
> interpretado pelo planner como expressão, não como literal.

### 2.2 Concatenação em `whereRaw` (Busca de Diárias)

`v6/app/Http/Controllers/Portal/BuscaDiariasController.php:33`:

```php
$ids = Servidor::whereRaw("translate(lower(name), 'àáâãäéèëêíìïîóòõöôúùüûçÇ',
    'aaaaaeeeeiiiiooooouuuucc') ilike '%" . $request->req_name . "%'")->pluck('id');
```

```
GET https://localhost:35979/requerimento-de-diarias?req_name=%27
HTTP 500
SQLSTATE[42601]: unterminated quoted string at or near "' and ("servidor"."deleted_at") is null"
```

### 2.3 O parâmetro `cpf` — a regra `min:14` **não** protege

`v6/app/Http/Requests/DiariasRequest.php` define apenas
`'cpf' => 'nullable|min:14'`. A regra exige **comprimento**, não **formato**,
e não restringe caracteres. Um payload de 14 caracteres que termina em aspas
simples passa na validação e quebra o SQL:

```
GET https://localhost:35979/requerimento-de-diarias?cpf=aaaaaaaaaaaaa%27
HTTP 500
SQLSTATE[42601]: unterminated quoted string at or near "' and ("servidor"."deleted_at") is null"
```

> Com payload de 13 caracteres a requisição é **redirecionada** (validação
> falha); com 14, o SQL quebra. A regra de validação éLength-based e
> completamente ortogonal à injeção.

### 2.4 `POST /legislacao/filtro` — parâmetro `termo`

`v6/app/Http/Controllers/Portal/LegislationController.php:203-204` concatena
`$request->termo` **sem** passar por `clean_slug()`:

```php
$query->whereRaw("translate(lower(title), '...') ilike '%" . $request->termo . "%'")
    ->orWhereRaw("translate(lower(text),  '...') ilike '%" . $request->termo . "%'");
```

Resposta real (com token CSRF válido):

```
POST https://localhost:35979/legislacao/filtro
HTTP 500
SQLSTATE[08P01]: bind message supplies 0 parameters, but prepared statement
"pdo_stmt_0000003c" requires 1
```

> O erro é diferente porque o `whereRaw` anterior (com binding) e o
> concatenado coexistem na mesma closure: o Doctrine DBAL detecta a
> inconsistência de parâmetros. Ainda é injeção — o valor não escapa.

---

## 3. O que `clean_slug()` protege — e o que não protege

`v6/app/Helpers/functions.php:823` normaliza texto removendo acentos e
caracteres fora de `[A-Za-z0-9. ]`:

```php
function clean_slug($string) {
    $nova_string = preg_replace(
        array(..., "/[^A-Za-z0-9. ]/"),
        explode(" ", "a A e E i I o O u U n N c C"),
        $string
    );
    ...
}
```

Por isso, os pontos que **passam** por `clean_slug()` (as expressões
`ilike` com `$newTXT`, `$newReq`, `$newTexto`) **não são exploráveis** —
o apóstrofo é removido. Verificado em runtime: os payloads nesses pontos
retornam HTTP 200 sem erro de SQL.

**Porém `clean_slug()` é uma sanitização por lista de permissões aplicada a
SQL — e não uma proteção de SQL.** Ela:

- não impede `%` e `_` (metacaracteres de `LIKE`) —allows *wildcard flooding*;
- é aplicada de forma **inconsistente**: no mesmo controller, um parâmetro
  passa por `clean_slug()` e outro não (`LegislationController` usa
  `clean_slug($request->texto)` na linha 125 e `$request->termo` cru na 203);
- protege **apenas** os pontos que a utilizam — não é uma garantia do modelo.

**Os pontos exploráveis são exatamente os que não passam por `clean_slug()`**
e os que leem `$_GET` diretamente.

---

## 4. Inventário completo dos pontos candidatos

### 4.1 `$_GET` interpolado em SQL — `v6` (4 pontos)

| Arquivo | Linha | Variável |
|---|---|---|
| `v6/app/Http/Controllers/Portal/LegislationController.php` | 56 | `{$_GET['ano']}` |
| `v6/app/Http/Controllers/Portal/PublicationController.php` | 56 | `{$_GET['ano']}` |
| `v6/app/Http/Controllers/Portal/AtasEPontosController.php` | 60 | `{$_GET['ano']}` |
| `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php` | 55 | `{$_GET['ano']}` |

Na **v5** existem 2 deles: `LegislationController.php:54` e
`PublicationController.php:52`. `AtasEPontosController` e
`ComiteDeInvestimentosController` são módulos novos da v6.

> `PortalController.php:319` também usa `$_GET['iframe']`, mas apenas para
> montar uma query string de URL — **não** é injeção.

### 4.2 `whereRaw` com concatenação de `$request` sem `clean_slug` — `v6`

| Arquivo | Linha | Variável | Explorável |
|---|---|---|---|
| `BuscaDiariasController.php` | 33 | `$request->req_name` | **SIM** (confirmado) |
| `BuscaDiariasController.php` | 38 | `$request->cpf` | **SIM** (confirmado) |
| `BuscaDiariasController.php` | 41 | `$request->num_diaria` | **SIM** (confirmado) |
| `LegislationController.php` | 203-204 | `$request->termo` | **SIM** (confirmado) |
| `LegislationController.php` | 75-76 | `$newReq` (= `clean_slug`) | não |
| `LegislationController.php` | 127-128 | `$newTexto` (= `clean_slug`) | não |
| `PublicationController.php` | 76-77 | `$newTXT` (= `clean_slug`) | não |
| `PublicationController.php` | 138 | `$newTexto` (= `clean_slug`) | não |
| `AtasEPontosController.php` | 80-81 | `$newTXT` (= `clean_slug`) | não |
| `ComiteDeInvestimentosController.php` | 75-76 | `$newTXT` (= `clean_slug`) | não |
| `BiddingOldController.php` | 70 | `request('informacoes')` | review — `transliterator_transliterate` não escapa aspas |

### 4.3 `v5` — mesmo padrão

`v5/app/Http/Controllers/Portal/BuscaDiariasController.php` linhas 33, 38, 41;
`LegislationController.php` 73-74 e 153-154; `PublicationController.php` 72-73.
**Todos confirmados exploráveis** em runtime contra a v5.

---

## 5. Impacto

| Capacidade | Viabilidade | Observação |
|---|---|---|
| Leitura de qualquer tabela do banco | **Sim** | `UNION SELECT` sobre query com colunas conhecidas; o `translate(...)` fixa a forma mas não impede `UNION` |
| Enumeração de estrutura (colunas) | **Sim** | PostgreSQL: `ORDER BY n` / `UNION SELECT` com erros de tipo |
| Extração de dados pessoais (servidores, CPF) | **Sim** | tabela `servidores` tem `name` e `cpf` |
| Extração de credenciais / hashes | **Provável** | tabela `users` guarda `password` (bcrypt) e `remember_token` |
| Escrita / alteração de dados | **Provável** | empilhamento de statements (`;`) depende do driver; `DB::select` usa prepared statement, o que limita `;` multi-statement, mas `UNION`/subqueries continuam livres |
| Escalada via `COPY`/`pg_read_file` | **Requer privilégio** | `pg_read_file` exige superusuário do PostgreSQL; o app conecta como `postgres` (superusuário) nos ambientes de desenvolvimento — **verificar** as credenciais de produção |

> **Ponto crítico a verificar:** nos arquivos `.env` de v5 e v6,
> `DB_USERNAME=postgres`. Se o ambiente de produção também usar o superusuário
> `postgres`, o impacto sobe para leitura de arquivos do servidor
> (`pg_read_file`, `pg_ls_dir`) e execução de comandos via `COPY ... PROGRAM`.

---

## 6. Correção recomendada

### 6.1 Imediato (bloqueia exploração)

Substituir **todo** acesso a `$_GET` por `$request` **com binding**:

```php
// ANTES (vulnerável)
$meses = DB::select("... extract(year from publication_date) = {$_GET['ano']} ...");

// DEPOIS (parametrizado)
$meses = DB::select(
    "select extract(month from publication_date) as mes
       from contents
      where category_contents_id = ?
        and extract(year from publication_date) = ?
        and publication_date <= NOW()
      group by mes
      order by extract(month from publication_date) asc",
    [$category->id, (int) $request->ano]
);
```

### 6.2 Padronização dos `whereRaw`

Usar **sempre** binding, sem concatenar:

```php
// ANTES (vulnerável)
->whereRaw("translate(lower(name), '...') ilike '%" . $request->req_name . "%'")

// DEPOIS (parametrizado)
->whereRaw("translate(lower(name), '...') ilike ?", ['%' . $request->req_name . '%'])
```

> O padrão `ilike ?` já é usado corretamente em
> `v6/app/Http/Controllers/Portal/RequerimentoController.php:100-108` —
> **copiar esse padrão**.

### 6.3 defence-in-depth

1. **Remover `clean_slug()` como proteção de SQL.** Ele deve ser usado
   apenas para normalização de exibição. Sanitização de SQL é responsibility
   do binding.
2. **Regra de lint** que proíba `$_GET`/`$_POST`/`$_REQUEST` em controllers
   (regra já existe no Doctrine/Security). No mínimo, `grep -rn '\$_GET'
   app/` deve retornar zero.
3. **Privilégio mínimo no PostgreSQL:** o usuário da aplicação **não** deve
   ser superusuário. Criar um role dedicado (`app_user`) com apenas
   `SELECT/INSERT/UPDATE/DELETE` nas tabelas necessárias.
4. **WAF / mod_security** ou regra de reverse-proxy bloqueando payloads
   típicos (`'`, `UNION`, `OR 1=1`) como camada de contingência — não como
   correção.

---

## 7. Impacto na reconstrução (v7)

- **Obrigatório:** toda query passa por **binding**; nenhum `DB::select` com
  interpolação; nenhum acesso a superglobais no controller.
- **Teste de segurança obrigatório** na suíte da v7: para cada endpoint que
  aceita input do usuário, um teste que injete `'` e `OR 1=1` e **assert que
  a resposta não contém `SQLSTATE`** e que o status é 200/422.
- **Postgres com privilégio mínimo** desde o primeiro dia.
- **A migração de dados deve usar `COPY`/migrações Laravel**, nunca SQL
  interpolado nas migrations (Laravel já usa binding).

---

## 8. Reprodução

```bash
# Instância v6 (https://localhost:35979)
curl -sk 'https://localhost:35979/legislacao/constituicao-federal?ano=%27' | grep -o 'SQLSTATE\[[^<]*'

# Instância v5 (https://localhost:42280)
curl -sk 'https://localhost:42280/legislacao/constituicao-federal?ano=%27' | grep -o 'SQLSTATE\[[^<]*'

curl -sk 'https://localhost:35979/requerimento-de-diarias?req_name=%27' | grep -o 'SQLSTATE\[[^<]*'
curl -sk 'https://localhost:35979/requerimento-de-diarias?num_diaria=%27' | grep -o 'SQLSTATE\[[^<]*'
curl -sk 'https://localhost:35979/requerimento-de-diarias?cpf=aaaaaaaaaaaaa%27' | grep -o 'SQLSTATE\[[^<]*'
```

Todos retornam **HTTP 500** com mensagem `SQLSTATE[42601]` (aspas não
terminadas) — prova de que o valor do usuário é concatenado no SQL.

---

## 9. Referências

| Item | Caminho |
|---|---|
| Ponto 1 (v6) | `v6/app/Http/Controllers/Portal/LegislationController.php:56` |
| Ponto 1 (v5) | `v5/app/Http/Controllers/Portal/LegislationController.php:54` |
| Ponto 2 (v6) | `v6/app/Http/Controllers/Portal/PublicationController.php:56` |
| Ponto 2 (v5) | `v5/app/Http/Controllers/Portal/PublicationController.php:52` |
| Ponto 3 (v6) | `v6/app/Http/Controllers/Portal/AtasEPontosController.php:60` |
| Ponto 4 (v6) | `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:55` |
| Ponto 5-7 (v6) | `v6/app/Http/Controllers/Portal/BuscaDiariasController.php:33,38,41` |
| Ponto 5-7 (v5) | `v5/app/Http/Controllers/Portal/BuscaDiariasController.php:33,38,41` |
| Ponto 8 (v6) | `v6/app/Http/Controllers/Portal/LegislationController.php:203-204` |
| Validação do `cpf` | `v6/app/Http/Requests/DiariasRequest.php` |
| `clean_slug()` | `v6/app/Helpers/functions.php:823` |
| Padrão correto (referência) | `v6/app/Http/Controllers/Portal/RequerimentoController.php:100-108` |
