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.
13 KiB
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_plateCHAR(7) ×vehicles.plateVARCHAR(10) → cast/seq scan. Fix: migrarpassages.vehicle_platepara VARCHAR/text. ⚠️ Migração pesada: reescreve a tabelapassagesinteira + revalida a FK → lock exclusivo no deploy. Decidir janela antes de aplicar.
Correção / edge cases
- A13 —
api_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. - A4 —
import.rs:28-34— limite de 10 MiB checado só depois de bufferizar o campo inteiro (mitigado: admin-only). Fix:DefaultBodyLimitna rota. - B10 —
camera_repository.rs:86,145,161,172—cameras.idé INT4;id as i32faz wrap de id i64 grande → age na câmera errada (admin-only). Fix: migrarcameras.idpara BIGINT e remover casts. - B12 —
whitelist_queue_repository.rs:42-67—mark_failednã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. - B14 —
driver_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). - B15 —
veiculo_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. - B16 —
driver_repository.rs:147,264,394—%/_não escapados nos ILIKE → matches largos demais (sem injection). Fix: escapar metacaracteres +ESCAPE '\'. - C7 —
plate_validator.rs:8,14,19—-?morto na regex +PlateValidator::normalizediverge deplate_normalizer::normalize_plate. Fix: delegar paranormalize_plate, remover-?. - C10 —
entities.rs:182-184—action()mapeia valor não-parseável →Add(fail-open). Fix: retornar Result/skip; fail-closed. ⚠️ só dispara com corrupção no banco. - C11 —
whitelist_queue_worker.rs:125— item com zero execuções viracompleted(0==0) sem tocar câmera nenhuma. Fix: tratartotal==0como não-completo/retry. ⚠️ só dispara com zero câmeras cadastradas. - D3 —
frontend/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. - D4 —
frontend/src/features/dashboard/index.tsx:64—nextOffsetincrementa mesmo em evento duplicado descartado pelo dedup → "Carregar mais" pula uma passagem. Fix: incrementar só quando um item é de fato inserido. - D7 —
frontend/src/features/settings/profile/profile-form.tsx:100-101—telegram_idtexto livre →Number("abc")=NaN→ serializanull→ limpa o campo silenciosamente. Fix: validar inteiro / rejeitar NaN. - D12 —
frontend/src/features/alerts/api/alerts.ts:60,74,100— mutations de criar alerta retornamdatasemalertSchema.parse(valor não é consumido → sem impacto atual). Fix:alertSchema.parse(data)ou remover o return. - D13 —
frontend/src/stores/auth-store.ts:27-31—JSON.parsedo estado persistido sem validação de shape. Fix: validar com zod, resetar no mismatch. - D14 —
frontend/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 noonopen/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
- A3 —
user_repository.rs:update_last_activity— oUPDATE last_activity(que rodava em todo request autenticado) ganhou guard noWHERE: só grava selast_activityfor nulo ou mais velho que 5 min; nos demais requests o UPDATE casa 0 linhas (sem escrita no heap/WAL). - A17 —
middleware.rs:require_auth—claims.sub.parse::<i64>()era computado duas vezes; agora roda uma vez só e oOption<i64>é reusado noupdate_last_activitye nouser_tag. - D1 —
handle-server-error.ts— passou a lerdata.errorcomo string primeiro (fallback.messagedefensivo); as mensagens reais do backend deixam de ser engolidas por texto genérico. - D5 —
user-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. - C3 —
telegram_service.rs:format_caption—plate/cameraagora passam porescape_html(&/</>) antes da captionparse_mode=HTML; nome de câmera com caractere especial não derruba mais o envio com 400 (coberto por teste). - C4 —
email_service.rs:send_alert—plate/location(inclusive dentro doalt="...") passam porescape_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-passworde(auth)/otp+ featuresauth/forgot-password/eauth/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_whitelistedincluemcompany_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/platee/api/unifirestritos à rede interna. Nova middlewarerequire_internal_ip(routes.rs) só nessas 2 rotas, filtrando pelo IP real do socket (ConnectInfo, à prova de spoof — não oX-Forwarded-For); permite 10/8, 172.16/12, 192.168/16 (viaIpv4Addr::is_private) + loopback, senão 403. Coberto por testes. ⚠️ Caveat: filtra o peer real; fecha o acesso direto ao:8080publicado. Se algum dia os webhooks passarem pelo Traefik (hostbunker), o IP de origem vira o do proxy — aí o bloqueio do caminho público precisa ser feito também no Traefik. - A5 —
VehicleRepository::updatedeixou de tocar no dono: removidosdriver_idda assinatura e do SQL (agora atualiza só brand/model/year/color/type). A propriedade é gerenciada só pelosassign_driver/assign_company(dono é XOR), então editar atributos de uma placa de empresa não viola mais o CHECKvehicles_single_owner(500) nem desatribui dono silenciosamente. Handler e os 2 callers de teste ajustados (test_update_vehicle_with_drivervirou regressão viaassign_driver). Validado num Postgres descartável: update preservacompany_ide o caminho antigo (setar driver_id) de fato violava o CHECK. - A1 — rate limiter do login não confia mais no
X-Forwarded-Forcru. NovoKeyExtractor(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:8080não é exposto à internet), com fallback pro IP do socket. Substitui oSmartIpKeyExtractor, 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 fazROLLBACK TO SAVEPOINTsó 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 + C6 —
available_spotsagora com.max(0)no dashboard (api_dashboard.rs) e no SSE (passage_broadcast.rs) → não mostra mais número negativo. - B4 —
count_authorized_entries_todayincluicompany_id(OR v.company_id IS NOT NULL;.sqlxregenerado) → métrica não subconta veículos de empresa. - A16 — create/update de usuário rejeitam email sem
@(api_users.rs). - C12 — deserializer do
typedo webhook ganhouvisit_f64/visit_bool→ um float/bool não derruba mais o parse inteiro. - D9 —
import/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). - A14 —
DATABASE_URLagora.expect(...)em vez do defaultsqlite:enganoso (config.rs). - B11 — comentário
Update a cameracorrigido (não mexe em passages). - C9 — comentário do crop corrigido (recorta 7% de cada borda, não só o topo).
- A11 + C6 —
- 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:
- A2 —
CorsLayer::permissive()agora só entra seENABLE_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::verifycontra 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/platenão devolve mais o erro interno cru no corpo do 500 (só logae; 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).
- A2 —
- Grupo performance (3/4), verificado com cargo check offline + 168 testes lib + build do frontend:
- B7 —
find_recent/find_recent_with_offsetdeixaram de usarCOUNT(*) OVER (...)(varria apassagesinteira); agora paginam e contam viacount_by_platessó as placas da página (fill_passage_counts)..sqlxregenerado. - C1 —
calculate_best_scalecorrigido: 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ó noisLoading→ sem request por tecla nem piscar a lista. - B6 fica aberto de propósito (migração pesada, ver Baixa).
- B7 —
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).