diff --git a/e2e/flows/20-all-features-smoke.spec.ts b/e2e/flows/20-all-features-smoke.spec.ts index da36dc325..d3c1c5b7f 100644 --- a/e2e/flows/20-all-features-smoke.spec.ts +++ b/e2e/flows/20-all-features-smoke.spec.ts @@ -311,9 +311,16 @@ test.describe("@smoke Rotas públicas (gate de CI)", () => { await page.goto("/login"); await page.fill(Sel.login.email, "smoke-fake@example.com"); await page.fill(Sel.login.password, "SenhaErrada@2025!"); - await page.locator(Sel.login.submit).first().click(); - await expect(page).toHaveURL(/\/login/, { timeout: 8_000 }); - await expect(page.locator(Sel.login.submit).first()).toBeEnabled({ timeout: 15_000 }); + // Issue #61: o click default tinha timeout de 10s e flutava em CI quando o + // form ficava no estado "submitting" por mais que ~10s (Vite cold start + + // SPA hydration + Supabase auth real). Garante estado-final-clicável e + // amplia timeout de click p/ alinhar com toBeEnabled abaixo. + const submit = page.locator(Sel.login.submit).first(); + await expect(submit).toBeVisible({ timeout: 15_000 }); + await expect(submit).toBeEnabled({ timeout: 15_000 }); + await submit.click({ timeout: 15_000 }); + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); + await expect(submit).toBeEnabled({ timeout: 15_000 }); }); // 95 · Negativo de recovery: /reset-password sem token NÃO habilita reset. diff --git a/supabase/functions/webhook-inbound/index.ts b/supabase/functions/webhook-inbound/index.ts index 5632c8651..b2e5f81bc 100644 --- a/supabase/functions/webhook-inbound/index.ts +++ b/supabase/functions/webhook-inbound/index.ts @@ -2,6 +2,13 @@ // Validates HMAC signature using the secret stored in env (referenced by the // endpoint row), records every event in inbound_webhook_events. // +// Hardening OPS-002 (auditoria back-end sênior 2026-05-22): +// • bot-protection por IP no boot do handler (60 req/min, 30min block) +// — evita DoS por inflação de inbound_webhook_events. +// • INSERT no inbound_webhook_events só acontece após HMAC validar +// OU se houver endpoint configurado mas signature inválida (registro +// forense limitado a callers que conhecem ao menos o slug). +// // Contract validation: // - v1 = passthrough (compat com produção). default. // - v2 = envelope strict { event, occurred_at, data, idempotency_key? } @@ -14,6 +21,7 @@ import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts"; import { buildPublicCorsHeaders } from "../_shared/cors.ts"; import { parseContract } from "../_shared/contracts/index.ts"; import { WebhookInboundSchemas } from "../_shared/contracts/schemas/webhook-inbound.ts"; +import { runBotProtection } from "../_shared/bot-protection.ts"; const corsHeaders = buildPublicCorsHeaders({ extraAllowHeaders: ["x-signature-256", "x-event", "accept-version"], @@ -43,6 +51,22 @@ function timingSafeEqual(a: string, b: string): boolean { Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + // OPS-002: rate-limit anti-DoS por IP antes de qualquer trabalho de DB. + // Webhooks legítimos têm baixa cadência (≪60/min por IP); caller espurioso + // que ultrapassa é blocked por 30min e nunca chega no INSERT. + const protection = await runBotProtection( + req, + { + endpoint: "webhook-inbound", + maxRequests: 60, + windowSeconds: 60, + blockSeconds: 1800, + allowSearchBots: false, + }, + corsHeaders, + ); + if (!protection.allowed) return protection.blockResponse!; + const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, diff --git a/vercel.json b/vercel.json index 7eb07dba6..d87fb1d48 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,41 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Strict-Transport-Security", + "value": "max-age=31536000; includeSubDomains; preload" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()" + }, + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.gpteng.co https://vercel.live https://*.vercel.app; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https: ; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.lovable.dev https://*.lovable.app https://*.vercel.app https://*.ingest.sentry.io https://*.glitchtip.io https://*.elevenlabs.io wss://*.elevenlabs.io https://api.cnpja.com https://*.bitrix24.com.br https://*.bitrix24.com; media-src 'self' blob: https:; worker-src 'self' blob:; frame-src 'self' https://vercel.live; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests" + } + ] + }, + { + "source": "/(.*)\\.(js|mjs|css|woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + } + ] }