Skip to content

[types] ?categorias=/?contas= do /historico chegam crus às colunas uuid — a mesma função valida tipos, de e ate três linhas acima #110

Description

@Guiroos

Onde

lib/utils/historico-params.ts:63-64

Três consumidores levam esses valores ao banco sem nenhuma checagem no caminho:

  1. app/(app)/historico/page.tsx:23getHistoricoFeed
  2. app/api/export/extrato/route.ts:20collectHistoricoItems
  3. lib/actions/historico.ts:7-9fetchMoreHistorico(params) recebe o objeto inteiro do cliente e repassa a getHistoricoFeed sem chamar parseHistoricoParams

Evidência

parseHistoricoParams valida três dos seus campos e deixa dois passarem crus, no mesmo return:

// :45-48 — tipos: filtrado contra ALL_TIPOS
const tipos: TipoKind[] = tiposRaw
  ? (tiposRaw.split(',').filter((t) => (ALL_TIPOS as readonly string[]).includes(t)) as TipoKind[])
  : [...ALL_TIPOS]

// :56-57 — de/ate: validados por z.string().date() (correção da #32/#83)
const de = deRaw && isoDateSchema.safeParse(deRaw).success ? deRaw : ninetyDaysAgoStr()
const ate = ateRaw && isoDateSchema.safeParse(ateRaw).success ? ateRaw : todayStr()

return {
  ...
  categorias: categoriasRaw ? categoriasRaw.split(',').filter(Boolean) : [],  // :63
  contas: contasRaw ? contasRaw.split(',').filter(Boolean) : [],              // :64

.filter(Boolean) remove string vazia — nada mais. O destino é coluna uuid:

// lib/queries/historico.ts:82-83  (e :91-92 para fixedExpenses)
categorias.length > 0 ? inArray(transactions.categoryId, categorias) : undefined,
contas.length > 0 ? inArray(transactions.accountId, contas) : undefined

lib/db/schema.ts:106,135,138,155,158,174,177categoryId e accountId são uuid() nas duas tabelas.

O tipo é verdade sintática e mentira semântica. HistoricoParams.categorias: string[] é literalmente verdade, e é por isso que nada acusa: o compilador não sabe que aquelas strings precisam ser UUIDs, e inArray aceita string[] de bom grado.

Verificações feitas (tentativa de falsificar o achado)

1. Executei a função (npx tsx, sobre o arquivo real do repo). O achado não morreu — e o controle na mesma execução mostra que os outros campos da mesma função se defendem:

categorias="abc"                                  -> ["abc"]                                  uuid-clean=false
categorias="' OR 1=1--"                           -> ["' OR 1=1--"]                           uuid-clean=false
categorias="00000000-0000-0000-0000-00000000000"  -> ["00000000-0000-0000-0000-00000000000"]  uuid-clean=false
CONTROLE tipos=lixo -> []
CONTROLE de=abc     -> 2026-05-22   (caiu no default de 90 dias)

2. Confirmei o SQL que o Drizzle emite — parâmetro sem cast, tipo inferido da coluna:

{ "sql": "select \"id\" from \"transactions\" where (\"transactions\".\"user_id\" = $1 and \"transactions\".\"category_id\" in ($2, $3))",
  "params": ["u", "abc", "def"] }

3. Executei esse statement num Postgres 16 local, na forma parametrizada exata:

postgres=# prepare q2 as select * from t where category_id in ($1, $2); execute q2('abc','def');
PREPARE
ERROR:  invalid input syntax for type uuid: "abc"

-- controle, uuid válido:
postgres=# execute q3('11111111-1111-1111-1111-111111111111');
 id | category_id
----+-------------
(0 rows)

SQLSTATE 22P02, não resultado vazio. É o mesmo modo de falha que o commit 8d05ff7 descreve ao fechar a #82 ("id malformado fazia o Postgres rejeitar o literal uuid, derrubando a rota em 500").

4. git log -L 44,66:lib/utils/historico-params.ts — não é escolha deliberada. Dois commits passaram por este bloco: 08abe34 (fecha #32) e 0f0770b (correção do helper de data). Os dois mexeram em de/ate, nas linhas imediatamente acima de categorias/contas, e não tocaram nelas. É omissão, não decisão.

5. A convenção do repo é validar, e ela já existe nos dois sites vizinhosapp/api/export/devedores/route.ts:35 (uuidSchema.safeParse(rawPersonId) → 404) e app/(app)/devedores/[id]/page.tsx:27 (uuidSchema.safeParse(id)notFound()). O /historico é a minoria.

6. Cobertura indireta: não existe — e o teste atual trava o comportamento errado. Ver abaixo.

Impacto

Quem tem um link de /historico com filtro de categoria ou conta. O app põe os filtros na URL por design (buildHistoricoUrl), então esses links circulam: bookmark, histórico do navegador, link colado num chat.

O caso realista não é o atacante — é truncamento. Um UUID tem 36 caracteres e a URL com dois ou três filtros passa fácil de 150; cliente de e-mail e app de mensagem cortam. …&categorias=550e8400-e29b-41d4-a716-4466554400 (35 hex em vez de 36) é malformado mas plausível, e produz exatamente o 22P02 acima.

Resultado hoje:

  • /historico?categorias=<lixo> — a página inteira cai no error boundary. Não é "filtro ignorado", é "algo deu errado": o usuário não vê a lista, não vê os filtros, e não tem como saber que basta apagar um pedaço da URL.
  • /api/export/extrato?categorias=<lixo> — 500 opaco, sem corpo. O download simplesmente não acontece.
  • fetchMoreHistorico (o "carregar mais" da HistoricoClient:189) é a via de entrada mais aberta das três: recebe HistoricoParams do cliente e não chama parseHistoricoParams em momento nenhum. Corrigir só a função de parse não fecha este caminho — é preciso normalizar dentro da action também.

O comportamento correto está definido pela própria função três linhas acima: ?tipos=lixo degrada (lista vazia), ?de=abc degrada (cai nos 90 dias). Só categoria e conta derrubam.

Cobertura

Não existe teste que pegue isso. Pior: o teste existente afirma o bug e quebraria com a correção certa

// __tests__/unit/historico-params.test.ts:32-36
it('parseia categorias e contas como arrays', () => {
  const result = parseHistoricoParams({ categorias: 'uuid1,uuid2', contas: 'uuid3' })
  expect(result.categorias).toEqual(['uuid1', 'uuid2'])   // 'uuid1' não é UUID
  expect(result.contas).toEqual(['uuid3'])
})

'uuid1' é justamente a entrada que a correção precisa rejeitar. A suíte fica verde sobre o furo, e quem implementar vai ver este teste falhar e pode "consertá-lo" de volta. Ele precisa ser reescrito com UUIDs reais, não relaxado. O de buildHistoricoUrl (:80) usa categorias: ['uuid1'] pelo mesmo motivo e não tem esse problema — serializa, não valida.

Casos novos que a correção certa passa:

  1. { categorias: 'abc' }[] — o caso trivial.
  2. { categorias: '00000000-0000-0000-0000-00000000000' }[] — 35 hex, o UUID truncado. Este é o que separa a correção certa da errada: um length === 36 ou um regex frouxo de "hex com hífens" deixa passar, e é a implementação mais provável de quem for pelo caminho rápido.
  3. { categorias: '<uuid válido>,abc' }['<uuid válido>'] — mistura; garante filtro por item, não descarte do array inteiro.
  4. Um teste sobre fetchMoreHistorico com categorias: ['abc'] no objeto de entrada. Sem ele, a correção fecha 2 dos 3 sites e o CI não acusa — foi exatamente assim que a [types] Mensagens de erro de Server Actions são mascaradas em produção — 5 componentes exibem err.message que nunca chega ao usuário #34 fechou pela metade (exigência 8).

Proposta

Filtrar por item, no mesmo espírito de tipos, usando uuidSchema de lib/validations/utils.ts:

const asUuids = (rawValue: string | undefined): string[] =>
  rawValue ? rawValue.split(',').filter((v) => uuidSchema.safeParse(v).success) : []

categorias: asUuids(categoriasRaw),
contas: asUuids(contasRaw),

Por que uuidSchema e não um regex local: é o helper que os dois sites que já validam usam (app/api/export/devedores/route.ts, app/(app)/devedores/[id]/page.tsx), então a definição de "uuid válido" fica uma só. Um regex escrito à mão aqui é o caminho natural — e é o que deixa passar o caso 2 acima. Também não usar dateSchema-style "valida formato e segue": o gotcha do CLAUDE.md sobre overflow silencioso é de data, mas a lição é a mesma — helper que parece certo pode não rejeitar o caso que importa.

Descartar em vez de lançar mantém a coerência com os outros três campos da função: filtro vindo de URL degrada, não derruba. A consequência a assumir explicitamente é que ?categorias=abc passa a mostrar tudo (filtro ignorado) em vez de nada — é o mesmo contrato de ?de=abc, que hoje ignora a data e cai nos 90 dias.

Fechar os três sites. O 1 e o 2 saem de graça com a mudança acima, porque ambos passam por parseHistoricoParams. O 3 não: fetchMoreHistorico precisa normalizar o que recebe antes de chamar getHistoricoFeed — os campos que chegam do cliente não passaram por parse nenhum, e HistoricoParams sendo um type não impõe nada em runtime. PR que cobrir só 1 e 2 deve usar refs, não closes.

Custo estimado

M (2-4 arquivos): lib/utils/historico-params.ts, lib/actions/historico.ts, __tests__/unit/historico-params.test.ts.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    claude-auditAchado da auditoria automáticaclaude-readyAprovado para implementaçãoclaude-wipJá tem PR aberto, não pegar de novotypes

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions