You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
lib/actions/historico.ts:7-9 — fetchMoreHistorico(params) recebe o objeto inteiro do cliente e repassa a getHistoricoFeedsem chamar parseHistoricoParams
Evidência
parseHistoricoParams valida três dos seus campos e deixa dois passarem crus, no mesmo return:
.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,177 — categoryId 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 vizinhos — app/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-36it('parseia categorias e contas como arrays',()=>{constresult=parseHistoricoParams({categorias: 'uuid1,uuid2',contas: 'uuid3'})expect(result.categorias).toEqual(['uuid1','uuid2'])// 'uuid1' não é UUIDexpect(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 só a correção certa passa:
{ categorias: 'abc' } → [] — o caso trivial.
{ 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.
{ categorias: '<uuid válido>,abc' } → ['<uuid válido>'] — mistura; garante filtro por item, não descarte do array inteiro.
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.
Onde
lib/utils/historico-params.ts:63-64Três consumidores levam esses valores ao banco sem nenhuma checagem no caminho:
app/(app)/historico/page.tsx:23→getHistoricoFeedapp/api/export/extrato/route.ts:20→collectHistoricoItemslib/actions/historico.ts:7-9—fetchMoreHistorico(params)recebe o objeto inteiro do cliente e repassa agetHistoricoFeedsem chamarparseHistoricoParamsEvidência
parseHistoricoParamsvalida três dos seus campos e deixa dois passarem crus, no mesmoreturn:.filter(Boolean)remove string vazia — nada mais. O destino é colunauuid:lib/db/schema.ts:106,135,138,155,158,174,177—categoryIdeaccountIdsãouuid()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, einArrayaceitastring[]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: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:
SQLSTATE 22P02, não resultado vazio. É o mesmo modo de falha que o commit
8d05ff7descreve 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) e0f0770b(correção do helper de data). Os dois mexeram emde/ate, nas linhas imediatamente acima decategorias/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 vizinhos —
app/api/export/devedores/route.ts:35(uuidSchema.safeParse(rawPersonId)→ 404) eapp/(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
/historicocom 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" daHistoricoClient:189) é a via de entrada mais aberta das três: recebeHistoricoParamsdo cliente e não chamaparseHistoricoParamsem 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=lixodegrada (lista vazia),?de=abcdegrada (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 —
'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 debuildHistoricoUrl(:80) usacategorias: ['uuid1']pelo mesmo motivo e não tem esse problema — serializa, não valida.Casos novos que só a correção certa passa:
{ categorias: 'abc' }→[]— o caso trivial.{ categorias: '00000000-0000-0000-0000-00000000000' }→[]— 35 hex, o UUID truncado. Este é o que separa a correção certa da errada: umlength === 36ou um regex frouxo de "hex com hífens" deixa passar, e é a implementação mais provável de quem for pelo caminho rápido.{ categorias: '<uuid válido>,abc' }→['<uuid válido>']— mistura; garante filtro por item, não descarte do array inteiro.fetchMoreHistoricocomcategorias: ['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 exibemerr.messageque nunca chega ao usuário #34 fechou pela metade (exigência 8).Proposta
Filtrar por item, no mesmo espírito de
tipos, usandouuidSchemadelib/validations/utils.ts:Por que
uuidSchemae 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 usardateSchema-style "valida formato e segue": o gotcha doCLAUDE.mdsobre 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=abcpassa 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:fetchMoreHistoricoprecisa normalizar o que recebe antes de chamargetHistoricoFeed— os campos que chegam do cliente não passaram por parse nenhum, eHistoricoParamssendo umtypenão impõe nada em runtime. PR que cobrir só 1 e 2 deve usarrefs, nãocloses.Custo estimado
M (2-4 arquivos):
lib/utils/historico-params.ts,lib/actions/historico.ts,__tests__/unit/historico-params.test.ts.