Skip to content

Commit ab58b20

Browse files
committed
fix(sec): hardening de crons, upload seguro de templates e indices de fks
- CI: adiciona step lint:role-rank no GitHub Actions - Upload: restringe rota de media de templates a role agent e valida magic bytes - Kit: restaura sessoes WAHA no restore.sh e torna update.sh fail-closed em falhas de backup - DB: cria migration 0239 com indices parciais em foreign keys de messages e ai_agent_runs - Auth: introduz autorizaCron com timingSafeEqual para prevencao de timing attacks - UI: remove prefetch estatico na Sidebar para evitar sobrecarga RSC
1 parent c98bf7c commit ab58b20

13 files changed

Lines changed: 360 additions & 36 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ jobs:
4747
- name: Channel provider leak
4848
run: pnpm lint:channels
4949

50+
# Auditoria de autorização e papel: impede comparações diretas de papel
51+
# fora de lib/auth/ que contornem o gate de MFA e requireRole().
52+
- name: Role rank audit
53+
run: pnpm lint:role-rank
54+
5055
- name: Unit tests
5156
run: pnpm test:unit
5257

app/api/v1/channels/partner/templates/media/route.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import { randomUUID } from "node:crypto";
2727
import type { NextRequest } from "next/server";
2828

2929
import { fail, ok } from "@/lib/api/wrappers";
30-
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
30+
import { requireRole } from "@/lib/auth/require-role";
31+
import { extensaoDe, farejarTipo, pareceSvg } from "@/lib/branding/logo-arquivo";
3132
import { traduzir } from "@/lib/i18n/dicionario";
3233
import { logger } from "@/lib/logger";
3334
import { createAdminClient } from "@/lib/supabase/admin";
@@ -37,13 +38,7 @@ export const dynamic = "force-dynamic";
3738
/** A revisão leva até 24h; a margem tem de caber num feriado. */
3839
const VALIDADE_SEGUNDOS = 7 * 24 * 60 * 60;
3940

40-
/**
41-
* Só imagem, e só os formatos que a plataforma aceita no cabeçalho.
42-
*
43-
* Recusar aqui é melhor que deixar subir: o arquivo iria para o storage, a
44-
* definição seria criada, e a recusa chegaria horas depois falando de um
45-
* formato que o operador escolheu porque a tela deixou.
46-
*/
41+
/** Só imagem, e só os formatos que a plataforma aceita no cabeçalho. */
4742
const TIPOS = new Set(["image/jpeg", "image/png"]);
4843
const TAMANHO_MAX = 5 * 1024 * 1024;
4944

@@ -53,38 +48,52 @@ export async function POST(req: NextRequest): Promise<Response> {
5348

5449
const requestId = randomUUID();
5550

56-
const user = await loadAuthUser();
57-
if (!user) return fail("unauthenticated", "Faça login.", 401, { requestId });
51+
const authz = await requireRole("agent", { requestId, resource: "channel_templates" });
52+
if (!authz.ok) return authz.response;
53+
const { user, org } = authz;
5854
const t = (texto: string) => traduzir(texto, user.idioma);
59-
const org = await resolveActiveOrg(user);
60-
if (!org) return fail("forbidden", t("Sem organização ativa."), 403, { requestId });
6155

6256
const form = await req.formData().catch(() => null);
6357
const file = form?.get("file");
6458
if (!(file instanceof File)) {
6559
return fail("validation_failed", t("Campo 'file' (multipart) obrigatório."), 422, { requestId });
6660
}
6761

62+
if (file.size > TAMANHO_MAX) {
63+
return fail("payload_too_large", t("A imagem precisa ter até 5 MB."), 413, { requestId });
64+
}
65+
6866
const mime = file.type || "application/octet-stream";
6967
if (!TIPOS.has(mime)) {
68+
return fail("unsupported_media_type", t("O cabeçalho aceita imagem JPG ou PNG."), 415, { requestId });
69+
}
70+
71+
const bytes = new Uint8Array(await file.arrayBuffer());
72+
const tipoReal = farejarTipo(bytes);
73+
if (!tipoReal || !TIPOS.has(tipoReal)) {
74+
if (pareceSvg(bytes)) {
75+
return fail(
76+
"unsupported_media_type",
77+
t("Arquivos SVG não são aceitos. O cabeçalho aceita imagem JPG ou PNG."),
78+
415,
79+
{ requestId },
80+
);
81+
}
7082
return fail(
7183
"unsupported_media_type",
72-
t("O cabeçalho aceita imagem JPG ou PNG."),
84+
t("O cabeçalho aceita imagem JPG ou PNG válida."),
7385
415,
7486
{ requestId },
7587
);
7688
}
77-
if (file.size > TAMANHO_MAX) {
78-
return fail("payload_too_large", t("A imagem precisa ter até 5 MB."), 413, { requestId });
79-
}
8089

81-
const ext = mime === "image/png" ? "png" : "jpg";
90+
const ext = extensaoDe(tipoReal);
8291
const caminho = `${org.orgId}/templates/${randomUUID()}.${ext}`;
8392
const admin = createAdminClient();
8493

8594
const { error: erroUp } = await admin.storage
8695
.from("whatsapp-media")
87-
.upload(caminho, Buffer.from(await file.arrayBuffer()), { contentType: mime, upsert: false });
96+
.upload(caminho, Buffer.from(bytes), { contentType: tipoReal, upsert: false });
8897
if (erroUp) {
8998
logger.error("[partner/templates/media] upload falhou", { detail: erroUp.message, requestId });
9099
return fail("internal_error", "Erro ao subir a imagem.", 500, { requestId });

app/api/v1/cron/data-retention/route.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import type { NextRequest } from "next/server";
5252

5353
import { ok, fail } from "@/lib/api/wrappers";
5454
import { audit } from "@/lib/audit";
55+
import { autorizaCron } from "@/lib/auth/cron-auth";
5556
import { env } from "@/lib/env";
5657
import { logger } from "@/lib/logger";
5758
import {
@@ -221,10 +222,7 @@ export function houveEfeito(resultado: ResultadoDaRetencao): boolean {
221222
async function handle(req: NextRequest): Promise<Response> {
222223
const requestId = randomUUID();
223224

224-
const auth = req.headers.get("authorization") ?? "";
225-
const provided = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
226-
const accepted = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(Boolean);
227-
if (accepted.length === 0 || !provided || !accepted.includes(provided)) {
225+
if (!autorizaCron(req)) {
228226
return fail("forbidden", "Cron secret missing or invalid.", 403, { requestId });
229227
}
230228

app/api/v1/cron/routing-worker/route.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { NextRequest } from "next/server";
1414

1515
import { ok, fail } from "@/lib/api/wrappers";
1616
import { audit } from "@/lib/audit";
17-
import { env } from "@/lib/env";
17+
import { autorizaCron } from "@/lib/auth/cron-auth";
1818
import { logger } from "@/lib/logger";
1919
import { runRoutingWorker } from "@/lib/routing/worker";
2020

@@ -23,11 +23,7 @@ export const dynamic = "force-dynamic";
2323
async function handle(req: NextRequest): Promise<Response> {
2424
const requestId = randomUUID();
2525

26-
const auth = req.headers.get("authorization") ?? "";
27-
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
28-
const provided = bearer || (req.headers.get("x-cron-secret")?.trim() ?? "");
29-
const accepted = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(Boolean);
30-
if (accepted.length === 0 || !provided || !accepted.includes(provided)) {
26+
if (!autorizaCron(req)) {
3127
return fail("forbidden", "Cron secret missing or invalid.", 403, { requestId });
3228
}
3329

components/shell/Sidebar.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,6 @@ export function SidebarContent({
243243
<li key={item.href}>
244244
<Link
245245
href={item.href}
246-
prefetch={true}
247246
title={collapsed ? t(item.label) : undefined}
248247
aria-current={isActive ? "page" : undefined}
249248
onClick={onNavigate}
@@ -270,7 +269,6 @@ export function SidebarContent({
270269
<li>
271270
<Link
272271
href={group.hub.href}
273-
prefetch={true}
274272
title={collapsed ? t(group.hub.label) : undefined}
275273
aria-current={pathname === group.hub.href ? "page" : undefined}
276274
onClick={onNavigate}
@@ -297,7 +295,6 @@ export function SidebarContent({
297295
{rodape && (
298296
<Link
299297
href={rodape.href}
300-
prefetch={true}
301298
title={collapsed ? t(rodape.label) : undefined}
302299
aria-current={pathname.startsWith(rodape.href) ? "page" : undefined}
303300
onClick={onNavigate}

hostgator-setup-kit/backup.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ c_grn "✓ banco: $(du -h "$BACKUP_DIR/db-$ts.sql.gz" | awk '{print $1}')"
2323
step "Snapshot das sessões do WhatsApp → $BACKUP_DIR/waha-$ts.tgz"
2424
vol="$(dc config --volumes 2>/dev/null | grep -m1 waha-data || echo '')"
2525
proj="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')"
26-
docker run --rm -v "${proj}_waha-data:/data:ro" -v "$BACKUP_DIR:/out" alpine:3.20 \
26+
vol="${vol:-${proj}_waha-data}"
27+
docker run --rm -v "${vol}:/data:ro" -v "$BACKUP_DIR:/out" alpine:3.20 \
2728
tar czf "/out/waha-$ts.tgz" -C /data . 2>/dev/null \
2829
&& c_grn "✓ sessões WhatsApp salvas" \
2930
|| c_ylw "⚠ não achei o volume waha-data (nome pode variar). Ajuste manualmente se necessário."
3031

3132
# Retenção: mantém os 14 mais recentes de cada tipo.
3233
step "Limpando backups antigos (mantém 14)"
33-
ls -1t "$BACKUP_DIR"/db-*.sql.gz 2>/dev/null | tail -n +15 | xargs -r rm -f
34-
ls -1t "$BACKUP_DIR"/waha-*.tgz 2>/dev/null | tail -n +15 | xargs -r rm -f
34+
(ls -1t "$BACKUP_DIR"/db-*.sql.gz 2>/dev/null || true) | tail -n +15 | xargs -r rm -f 2>/dev/null || true
35+
(ls -1t "$BACKUP_DIR"/waha-*.tgz 2>/dev/null || true) | tail -n +15 | xargs -r rm -f 2>/dev/null || true
3536
c_grn "✓ backup concluído em $BACKUP_DIR"

hostgator-setup-kit/restore.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,19 @@ step "Restaurando $DUMP"
1717
gunzip -c "$DUMP" | docker run --rm -i postgres:17-alpine psql "$(url_do_schema)" \
1818
&& c_grn "✓ banco restaurado" || die "Falha na restauração — veja o log acima."
1919

20+
# Restaura o estado das sessões do WhatsApp (WAHA) se o snapshot emparelhado existir
21+
WAHA_TAR="${DUMP/db-/waha-}"
22+
WAHA_TAR="${WAHA_TAR%.sql.gz}.tgz"
23+
if [ -f "$WAHA_TAR" ]; then
24+
step "Restaurando sessões do WhatsApp de $WAHA_TAR"
25+
vol="$(dc config --volumes 2>/dev/null | grep -m1 waha-data || echo '')"
26+
proj="$(basename "$PROJECT_DIR" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')"
27+
vol="${vol:-${proj}_waha-data}"
28+
WAHA_DIR="$(cd "$(dirname "$WAHA_TAR")" && pwd)"
29+
WAHA_FILE="$(basename "$WAHA_TAR")"
30+
docker run --rm -v "${vol}:/data" -v "${WAHA_DIR}:/in:ro" alpine:3.20 \
31+
sh -c "rm -rf /data/* && tar xzf /in/${WAHA_FILE} -C /data" \
32+
&& c_grn "✓ sessões do WhatsApp restauradas" || c_ylw "⚠ Falha ao restaurar sessões do WhatsApp"
33+
fi
34+
2035
c_ylw "Reinicie o app: docker compose $(dc_files) restart app"

hostgator-setup-kit/update.sh

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,13 @@ if [ -z "$SKIP_BACKUP" ]; then
115115
if bash "$(dirname "$0")/backup.sh"; then
116116
c_grn "✓ backup feito — se algo der errado, dá pra restaurar (restore.sh)."
117117
else
118+
if [ -n "${DESKCOMM_AGENT_REPORT:-}" ] || [ ! -t 0 ]; then
119+
die "O backup preventivo falhou. Atualização automática interrompida para proteger os dados."
120+
fi
118121
c_ylw "⚠ o backup falhou. A atualização NÃO apaga dados (só reorganiza os contatos),"
119-
c_ylw " mas o ideal é ter backup. Ctrl+C pra parar e investigar; continuo em 8s…"
120-
sleep 8
122+
c_ylw " mas o ideal é ter backup."
123+
read -r -p "Deseja continuar MESMO SEM BACKUP? Digite 'CONTINUAR': " conf
124+
[ "$conf" = "CONTINUAR" ] || die "Atualização cancelada pelo operador para investigar a falha do backup."
121125
fi
122126
fi
123127
# Avisa o agente do host (se for ele quem está dirigindo) — é o que faz a tela

lib/auth/cron-auth.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createHash, timingSafeEqual } from "node:crypto";
2+
import type { NextRequest } from "next/server";
3+
import { env } from "@/lib/env";
4+
5+
/**
6+
* Compara duas strings em tempo constante usando SHA-256 e timingSafeEqual.
7+
* O hashing prévio garante comprimento fixo (32 bytes), prevenindo tanto
8+
* timing attacks no conteúdo quanto vazamento do tamanho da string via early return.
9+
*/
10+
export function timingSafeStringEqual(a: string, b: string): boolean {
11+
if (!a || !b) return false;
12+
const hashA = createHash("sha256").update(a).digest();
13+
const hashB = createHash("sha256").update(b).digest();
14+
return timingSafeEqual(hashA, hashB);
15+
}
16+
17+
/**
18+
* Valida a autenticação de chamadas internas de cron.
19+
* Suporta header `Authorization: Bearer <secret>` e fallback para `x-cron-secret: <secret>`.
20+
* Compara em tempo constante contra `INTERNAL_CRON_SECRET` e `INTERNAL_SECRET`.
21+
*
22+
* Fail-closed: se nenhum secret estiver configurado no ambiente ou nenhum token for fornecido,
23+
* recusa imediatamente com false.
24+
*/
25+
export function autorizaCron(req: NextRequest): boolean {
26+
const auth = req.headers.get("authorization") ?? "";
27+
const bearer = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
28+
const headerSecret = req.headers.get("x-cron-secret")?.trim() ?? "";
29+
const provided = bearer || headerSecret;
30+
31+
if (!provided) {
32+
return false;
33+
}
34+
35+
const accepted = [env.INTERNAL_CRON_SECRET, env.INTERNAL_SECRET].filter(
36+
(s): s is string => typeof s === "string" && s.length > 0,
37+
);
38+
39+
if (accepted.length === 0) {
40+
return false;
41+
}
42+
43+
return accepted.some((secret) => timingSafeStringEqual(provided, secret));
44+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
-- Índices em chaves estrangeiras de mensagens e execuções de agentes de IA.
2+
--
3+
-- Motivação (Auditoria de Banco / Supabase Best Practices):
4+
-- Chaves estrangeiras sem índice em tabelas de alto volume geram varreduras
5+
-- sequenciais (sequential scan) inteiras na tabela filha durante deleções ou
6+
-- updates em cascata na tabela pai (ex: exclusão de contatos, encerramento de
7+
-- sessões de canal, rotação ou expurgo de conversas/mensagens via LGPD).
8+
--
9+
-- Além disso, consultas de histórico por contato ou sessão em messages e
10+
-- ai_agent_runs passam a se beneficiar de index scans btree com filtros parciais.
11+
--
12+
-- Idempotente: `if not exists` em cada índice.
13+
14+
-- 1. Tabela messages
15+
create index if not exists idx_messages_contact_id
16+
on public.messages (contact_id)
17+
where contact_id is not null;
18+
19+
create index if not exists idx_messages_channel_session_id
20+
on public.messages (channel_session_id)
21+
where channel_session_id is not null;
22+
23+
-- 2. Tabela ai_agent_runs
24+
create index if not exists idx_ai_agent_runs_contact_id
25+
on public.ai_agent_runs (contact_id)
26+
where contact_id is not null;
27+
28+
create index if not exists idx_ai_agent_runs_channel_session_id
29+
on public.ai_agent_runs (channel_session_id)
30+
where channel_session_id is not null;
31+
32+
create index if not exists idx_ai_agent_runs_conversation_id
33+
on public.ai_agent_runs (conversation_id)
34+
where conversation_id is not null;
35+
36+
create index if not exists idx_ai_agent_runs_inbound_message_id
37+
on public.ai_agent_runs (inbound_message_id)
38+
where inbound_message_id is not null;
39+
40+
create index if not exists idx_ai_agent_runs_outbound_message_id
41+
on public.ai_agent_runs (outbound_message_id)
42+
where outbound_message_id is not null;

0 commit comments

Comments
 (0)