Files
Alexandre Possebom cc4ac80b13 fix: resolve 31 bug-hunt findings across backend + SPA
Adversarial bug-hunt (Hunter -> Skeptic -> Referee) surfaced 48 confirmed
issues; this fixes 31 (0 critical, all 9 medium, 22 low). Tracked in BUGS.md.

Security:
- login rate limiter keys on the real client IP (rightmost X-Forwarded-For,
  not the spoofable leftmost) via a custom KeyExtractor (A1)
- /api/plate + /api/unifi webhooks restricted to internal IPs (A6)
- constant-time gate API-key compare (A7); login timing equalized with a
  dummy bcrypt hash to stop user enumeration (A8)
- public webhook no longer leaks raw internal errors (A9); SSE revalidates
  the user on open + every 60s (A10); CORS gated behind ENABLE_CORS (A2)

Data integrity:
- vehicle update no longer touches ownership (no XOR-CHECK 500 / silent
  unassign); owner stays managed via assign-driver/assign-company (A5)
- driver import isolates each row in a SAVEPOINT so one bad row no longer
  discards the whole import (C2)

Company-owner parity (migration 20260705000001):
- whitelist trigger fires on driver_id OR company_id; is_whitelisted /
  authorized-entries metric / vehicles list now treat company-owned
  vehicles as first-class owners (B3, B4, D8)

Correctness / perf:
- available_spots clamped at 0 (A11, C6); alert/user validation, webhook
  deserializer float/bool, plate plural, dead code + stale comments
  (A13, A16, C7, C10..C12, A12, A14, B11, C9, D9)
- dashboard passage counts via count_by_plates instead of a full-table
  window function (B7); TurboJPEG scaled-decode actually activates (C1);
  owner comboboxes debounced 250ms (D10)

Frontend:
- correct backend error-message extraction (D1); login accepts 6-char
  passwords (D5); dead forgot-password/OTP flow removed (D6)

Deferred: B6 (passages.vehicle_plate CHAR(7) -> VARCHAR) is a heavy table
rewrite + FK revalidation; left open in BUGS.md for a maintenance window.
2026-07-06 08:53:08 -03:00

13 KiB
Raw Permalink Blame History

BUGS

Bugs abertos levantados pelo bug-hunt adversarial (Hunter → Skeptic → Referee, 4 fatias: backend HTTP/auth, repositories/SQL, services/utils/models, frontend) em 2026-07-05.

61 achados brutos → 13 descartados como falsos positivos → 48 confirmados (0 críticos, 9 médios, 39 baixos). Deste total, 31 já foram corrigidos (ver Resolvidos), restando 17 abertos (0 críticos, 0 médios, 17 baixos). IDs preservam o prefixo da fatia de origem (A/B/C/D).


🟠 Severidade Média (0)

Todos os médios resolvidos. (ver Resolvidos)


🟡 Severidade Baixa (17)

Performance

  • B6 — join passages.vehicle_plate CHAR(7) × vehicles.plate VARCHAR(10) → cast/seq scan. Fix: migrar passages.vehicle_plate para VARCHAR/text. ⚠️ Migração pesada: reescreve a tabela passages inteira + revalida a FK → lock exclusivo no deploy. Decidir janela antes de aplicar.

Correção / edge cases

  • A13api_alerts.rs:130-176 — criar alerta com alvo inexistente → FK 23503 vira 500 genérico (não 404/400). Fix: pré-checar alvo ou mapear 23503.
  • A4import.rs:28-34 — limite de 10 MiB checado só depois de bufferizar o campo inteiro (mitigado: admin-only). Fix: DefaultBodyLimit na rota.
  • B10camera_repository.rs:86,145,161,172cameras.id é INT4; id as i32 faz wrap de id i64 grande → age na câmera errada (admin-only). Fix: migrar cameras.id para BIGINT e remover casts.
  • B12whitelist_queue_repository.rs:42-67mark_failed não-transacional + reseta execuções FAILED→PENDING mesmo indo pra terminal → execuções PENDING órfãs. Fix: tx única; não resetar quando vai terminal.
  • B14driver_repository.rs:237 (+passages) — ORDER BY paginado em coluna não-única sem tiebreaker → pode pular/duplicar entre páginas. Fix: adicionar tiebreaker único (, id).
  • B15veiculo_repository.rs:317-345 — merge commita a tx antes de renomear os JPEGs → janela de crash deixa thumbnail quebrada (recuperável). Fix: renomear antes (idempotente) ou passo de reconciliação.
  • B16driver_repository.rs:147,264,394%/_ não escapados nos ILIKE → matches largos demais (sem injection). Fix: escapar metacaracteres + ESCAPE '\'.
  • C7plate_validator.rs:8,14,19-? morto na regex + PlateValidator::normalize diverge de plate_normalizer::normalize_plate. Fix: delegar para normalize_plate, remover -?.
  • C10entities.rs:182-184action() mapeia valor não-parseável → Add (fail-open). Fix: retornar Result/skip; fail-closed. ⚠️ só dispara com corrupção no banco.
  • C11whitelist_queue_worker.rs:125 — item com zero execuções vira completed (0==0) sem tocar câmera nenhuma. Fix: tratar total==0 como não-completo/retry. ⚠️ só dispara com zero câmeras cadastradas.
  • D3frontend/src/features/dashboard/index.tsx:39-41 — seed REST e SSE competem; passagem ao vivo que chega antes do seed é sobrescrita. Fix: merge/dedupe no estado em vez de replace.
  • D4frontend/src/features/dashboard/index.tsx:64nextOffset incrementa mesmo em evento duplicado descartado pelo dedup → "Carregar mais" pula uma passagem. Fix: incrementar só quando um item é de fato inserido.
  • D7frontend/src/features/settings/profile/profile-form.tsx:100-101telegram_id texto livre → Number("abc")=NaN → serializa null → limpa o campo silenciosamente. Fix: validar inteiro / rejeitar NaN.
  • D12frontend/src/features/alerts/api/alerts.ts:60,74,100 — mutations de criar alerta retornam data sem alertSchema.parse (valor não é consumido → sem impacto atual). Fix: alertSchema.parse(data) ou remover o return.
  • D13frontend/src/stores/auth-store.ts:27-31JSON.parse do estado persistido sem validação de shape. Fix: validar com zod, resetar no mismatch.
  • D14frontend/src/features/dashboard/hooks/use-live-passages.ts:32-35 — token Bearer do SSE congelado no mount → obsoleto em reconexão/troca de token. Fix: ler o token no onopen/reconnect (ou via ref).

⚠️ Flags para revisão manual

  • B4 — mesmo ponto cego de empresa do B3 (já resolvido: decidiu-se que veículo de empresa abre a cancela): a métrica "entradas autorizadas hoje" ainda não conta veículos de empresa. Correção trivial (OR company_id IS NOT NULL), alinhada à decisão já tomada no B3.
  • C10 / C11 — confiança Média: só disparam com corrupção no banco (C10) ou zero câmeras cadastradas (C11).

Resolvidos

  • A3user_repository.rs:update_last_activity — o UPDATE last_activity (que rodava em todo request autenticado) ganhou guard no WHERE: só grava se last_activity for nulo ou mais velho que 5 min; nos demais requests o UPDATE casa 0 linhas (sem escrita no heap/WAL).
  • A17middleware.rs:require_authclaims.sub.parse::<i64>() era computado duas vezes; agora roda uma vez só e o Option<i64> é reusado no update_last_activity e no user_tag.
  • D1handle-server-error.ts — passou a ler data.error como string primeiro (fallback .message defensivo); as mensagens reais do backend deixam de ser engolidas por texto genérico.
  • D5user-auth-form.tsx — removida a checagem de tamanho no login (sobra só min(1)); o servidor é a autoridade na autenticação, então senha de 6 chars (ou legada) não trava mais.
  • C3telegram_service.rs:format_captionplate/camera agora passam por escape_html (&/</>) antes da caption parse_mode=HTML; nome de câmera com caractere especial não derruba mais o envio com 400 (coberto por teste).
  • C4email_service.rs:send_alertplate/location (inclusive dentro do alt="...") passam por escape_html (&/</>/") antes do corpo HTML do email de alerta; caractere especial não quebra mais a marcação nem o atributo (coberto por teste).
  • D6 — removido o fluxo morto de recuperação de senha: link "Esqueceu sua senha?" em user-auth-form.tsx + rotas (auth)/forgot-password e (auth)/otp + features auth/forgot-password/ e auth/otp/ (faziam POST em endpoints inexistentes). Route tree regenerada; build verde. Sobra órfão: components/ui/input-otp.tsx (primitivo do template, mantido).
  • B3 — veículos de empresa agora entram na whitelist das câmeras, em paridade com motorista. Migration 20260705000001: trigger reescrito para lógica "tem dono" (driver OU company), disparando em ambas as colunas (swap motorista↔empresa não gera mais REMOVE espúrio) + backfill ADD dos veículos de empresa existentes; is_whitelisted/count_whitelisted incluem company_id. Validado num Postgres descartável: toda a cadeia de migrations aplica e o trigger enfileira ADD/REMOVE corretamente. ⚠️ Impacto físico: ao deployar, o backfill sincroniza os carros de empresa às câmeras (passam a abrir a cancela) — comportamento pretendido do B3.
  • A6 — webhooks /api/plate e /api/unifi restritos à rede interna. Nova middleware require_internal_ip (routes.rs) só nessas 2 rotas, filtrando pelo IP real do socket (ConnectInfo, à prova de spoof — não o X-Forwarded-For); permite 10/8, 172.16/12, 192.168/16 (via Ipv4Addr::is_private) + loopback, senão 403. Coberto por testes. ⚠️ Caveat: filtra o peer real; fecha o acesso direto ao :8080 publicado. Se algum dia os webhooks passarem pelo Traefik (host bunker), o IP de origem vira o do proxy — aí o bloqueio do caminho público precisa ser feito também no Traefik.
  • A5VehicleRepository::update deixou de tocar no dono: removidos driver_id da assinatura e do SQL (agora atualiza só brand/model/year/color/type). A propriedade é gerenciada só pelos assign_driver/assign_company (dono é XOR), então editar atributos de uma placa de empresa não viola mais o CHECK vehicles_single_owner (500) nem desatribui dono silenciosamente. Handler e os 2 callers de teste ajustados (test_update_vehicle_with_driver virou regressão via assign_driver). Validado num Postgres descartável: update preserva company_id e o caminho antigo (setar driver_id) de fato violava o CHECK.
  • A1 — rate limiter do login não confia mais no X-Forwarded-For cru. Novo KeyExtractor (RealClientIpKeyExtractor, routes.rs) chaveia pela ponta direita do XFF (o hop que o Traefik anexa = cliente real, não forjável pelo público, e o :8080 não é exposto à internet), com fallback pro IP do socket. Substitui o SmartIpKeyExtractor, que lia a ponta esquerda (escrita pelo cliente) → rotacionar o header burlava o limite. Coberto por testes (XFF forjado à esquerda é ignorado; fallback pro peer sem XFF).
  • C2 — import de motoristas passou a rodar cada linha num savepoint (tx.begin() aninhado): um erro de banco numa linha faz ROLLBACK TO SAVEPOINT só dela e a transação externa segue, então as linhas boas persistem e o relatório fica fiel (contadores em memória restaurados no rollback). Antes, um único erro envenenava a tx e o commit final descartava o import inteiro. Validado num Postgres descartável: sem savepoint → 0 linhas; com savepoint → as boas persistem, a que falhou some.
  • Lote de baixos (baixo risco), verificado com cargo check offline + 168 testes lib + build do frontend:
    • A11 + C6available_spots agora com .max(0) no dashboard (api_dashboard.rs) e no SSE (passage_broadcast.rs) → não mostra mais número negativo.
    • B4count_authorized_entries_today inclui company_id (OR v.company_id IS NOT NULL; .sqlx regenerado) → métrica não subconta veículos de empresa.
    • A16 — create/update de usuário rejeitam email sem @ (api_users.rs).
    • C12 — deserializer do type do webhook ganhou visit_f64/visit_bool → um float/bool não derruba mais o parse inteiro.
    • D9import/index.tsx: linha(s) → plural correto por contagem (linha/linhas).
    • A12 — removido dead code Claims::needs_refresh/refresh + a const de threshold + os 6 testes associados (jwt.rs).
    • A14DATABASE_URL agora .expect(...) em vez do default sqlite: enganoso (config.rs).
    • B11 — comentário Update a camera corrigido (não mexe em passages).
    • C9 — comentário do crop corrigido (recorta 7% de cada borda, não só o topo).
  • D8 — lista de veículos: a coluna "Motorista" virou "Proprietário" (vehicles-columns.tsx), mostrando o motorista (link /drivers/{id}) ou a empresa (link /companies/{id}) conforme o dono (XOR) — carro de empresa não aparece mais como "—". Fecha o eixo empresa (B3/B4/D8). Build verde.
  • Grupo segurança (5), verificado com cargo check offline + 168 testes lib:
    • A2CorsLayer::permissive() agora só entra se ENABLE_CORS=true (routes.rs); off por padrão (prod é same-origin; o Vite já faz proxy em dev).
    • A7 — chave da gate comparada em tempo constante (ct_eq, gate.rs) em vez de == → sem timing oracle byte a byte.
    • A8 — login gasta um bcrypt::verify contra hash dummy quando o email não existe (api_auth.rs) → sem oráculo de timing pra enumerar usuários.
    • A9 — webhook público /api/plate não devolve mais o erro interno cru no corpo do 500 (só loga e; resposta genérica).
    • A10 — stream SSE valida o usuário na abertura (get_current_user) e revalida a cada 60s, encerrando se o usuário for deletado (api_dashboard.rs).
  • Grupo performance (3/4), verificado com cargo check offline + 168 testes lib + build do frontend:
    • B7find_recent/find_recent_with_offset deixaram de usar COUNT(*) OVER (...) (varria a passages inteira); agora paginam e contam via count_by_plates só as placas da página (fill_passage_counts). .sqlx regenerado.
    • C1calculate_best_scale corrigido: fatores em ordem crescente + fallback 1/1 no upscale → o scaled-decode do DCT do TurboJPEG passa a ativar (antes retornava sempre 1/1). 3 testes que codificavam o bug atualizados.
    • D10 — os 5 comboboxes de busca usam useDebouncedValue(250ms) (hook extraído p/ hooks/use-debounced-value.ts, reusado pelo command-menu) e spinner só no isLoading → sem request por tecla nem piscar a lista.
    • B6 fica aberto de propósito (migração pesada, ver Baixa).

Falsos positivos descartados (13)

Investigados e refutados pelo Referee (não são bugs): A15 (FK CASCADE), B1/B2 (FK SET NULL), B5 (offset clampado no caller), B8/B9 (CHECK XOR impede corrupção), B13 (worker único + idempotente), B17 (limit/offset clampados), B18 (pré-check 404), C5 (UPSERT ON CONFLICT), C8 (TZ setado no docker-compose), D2 (401 tratado no QueryCache), D11 (página já admin-gated).