Files
agenciapsilmno/database-novo/migrations/20260419000007_bot_defense_rpcs.sql
T
Leonardo 7c20b518d4 Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.

Ver commit.md na raiz para descricao completa por sessao.

# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)

# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)

# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)

# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 15:42:46 -03:00

300 lines
12 KiB
PL/PgSQL

-- =============================================================================
-- Migration: 20260419000007_bot_defense_rpcs
-- A#20 (rev2) — RPCs da defesa em camadas:
-- • check_rate_limit — consulta + decide allowed/captcha/bloqueio
-- • record_submission_attempt — log + atualiza contadores e bloqueios
-- • generate_math_challenge — cria pergunta math, retorna {id, question}
-- • verify_math_challenge — valida {id, answer}, marca used
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────
-- check_rate_limit
-- Lê config + estado atual, decide o que retornar.
-- Se fora da janela atual, "rolha" os contadores (reset).
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.check_rate_limit(
p_ip_hash text,
p_endpoint text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
rl submission_rate_limits%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
v_in_window boolean;
v_requires_captcha boolean := false;
v_blocked_until timestamptz;
v_retry_after_seconds integer := 0;
BEGIN
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND THEN
-- Sem config: fail-open (libera). Logado.
RETURN jsonb_build_object('allowed', true, 'requires_captcha', false, 'reason', 'no_config');
END IF;
-- Modo paranoid global: sempre captcha
IF cfg.captcha_required_globally THEN
v_requires_captcha := true;
END IF;
-- Sem rate limit ativo: libera (mas pode exigir captcha pelo paranoid)
IF NOT cfg.rate_limit_enabled THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'paranoid_global' ELSE 'rate_limit_disabled' END
);
END IF;
-- Sem ip_hash: libera (não dá pra rastrear)
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', 'no_ip'
);
END IF;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
-- Bloqueio temporário ativo?
IF FOUND AND rl.blocked_until IS NOT NULL AND rl.blocked_until > v_now THEN
v_retry_after_seconds := EXTRACT(EPOCH FROM (rl.blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'blocked'
);
END IF;
-- Captcha condicional ativo?
IF FOUND AND rl.requires_captcha_until IS NOT NULL AND rl.requires_captcha_until > v_now THEN
v_requires_captcha := true;
END IF;
-- Janela atual ainda válida?
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
v_in_window := FOUND AND rl.window_start >= v_window_start;
IF v_in_window AND rl.attempt_count >= cfg.rate_limit_max_attempts THEN
-- Excedeu — bloqueia
v_blocked_until := v_now + (cfg.block_duration_min || ' minutes')::interval;
UPDATE submission_rate_limits
SET blocked_until = v_blocked_until,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
v_retry_after_seconds := EXTRACT(EPOCH FROM (v_blocked_until - v_now))::int;
RETURN jsonb_build_object(
'allowed', false,
'requires_captcha', false,
'retry_after_seconds', v_retry_after_seconds,
'reason', 'rate_limit_exceeded'
);
END IF;
RETURN jsonb_build_object(
'allowed', true,
'requires_captcha', v_requires_captcha,
'reason', CASE WHEN v_requires_captcha THEN 'captcha_required' ELSE 'ok' END
);
END;
$function$;
REVOKE ALL ON FUNCTION public.check_rate_limit(text, text) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.check_rate_limit(text, text) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- record_submission_attempt
-- Loga em public_submission_attempts + atualiza submission_rate_limits.
-- Se !success: incrementa fail_count; se >= captcha_after_failures, marca
-- requires_captcha_until = now + captcha_required_window_min.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.record_submission_attempt(
p_endpoint text,
p_ip_hash text,
p_success boolean,
p_blocked_by text DEFAULT NULL,
p_error_code text DEFAULT NULL,
p_error_msg text DEFAULT NULL,
p_user_agent text DEFAULT NULL,
p_metadata jsonb DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
cfg saas_security_config%ROWTYPE;
v_now timestamptz := now();
v_window_start timestamptz;
rl submission_rate_limits%ROWTYPE;
BEGIN
-- Log sempre (mesmo sem ip)
INSERT INTO public_submission_attempts
(endpoint, ip_hash, success, blocked_by, error_code, error_msg, user_agent, metadata)
VALUES
(p_endpoint, p_ip_hash, p_success, p_blocked_by,
left(coalesce(p_error_code, ''), 80),
left(coalesce(p_error_msg, ''), 500),
left(coalesce(p_user_agent, ''), 500),
p_metadata);
-- Sem ip ou rate limit desligado: não atualiza contador
IF p_ip_hash IS NULL OR length(btrim(p_ip_hash)) = 0 THEN RETURN; END IF;
SELECT * INTO cfg FROM saas_security_config WHERE id = true;
IF NOT FOUND OR NOT cfg.rate_limit_enabled THEN RETURN; END IF;
v_window_start := v_now - (cfg.rate_limit_window_min || ' minutes')::interval;
SELECT * INTO rl
FROM submission_rate_limits
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF NOT FOUND THEN
INSERT INTO submission_rate_limits
(ip_hash, endpoint, attempt_count, fail_count, window_start, last_attempt_at)
VALUES
(p_ip_hash, p_endpoint, 1, CASE WHEN p_success THEN 0 ELSE 1 END, v_now, v_now);
ELSE
IF rl.window_start < v_window_start THEN
-- Reset janela
UPDATE submission_rate_limits
SET attempt_count = 1,
fail_count = CASE WHEN p_success THEN 0 ELSE 1 END,
window_start = v_now,
last_attempt_at = v_now,
blocked_until = NULL
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
ELSE
UPDATE submission_rate_limits
SET attempt_count = attempt_count + 1,
fail_count = fail_count + CASE WHEN p_success THEN 0 ELSE 1 END,
last_attempt_at = v_now
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
-- Se atingiu threshold de captcha condicional, marca
IF NOT p_success THEN
SELECT * INTO rl FROM submission_rate_limits WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
IF rl.fail_count >= cfg.captcha_after_failures
AND (rl.requires_captcha_until IS NULL OR rl.requires_captcha_until < v_now) THEN
UPDATE submission_rate_limits
SET requires_captcha_until = v_now + (cfg.captcha_required_window_min || ' minutes')::interval
WHERE ip_hash = p_ip_hash AND endpoint = p_endpoint;
END IF;
END IF;
END IF;
END;
$function$;
REVOKE ALL ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.record_submission_attempt(text, text, boolean, text, text, text, text, jsonb) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- generate_math_challenge
-- Cria 2 inteiros 1..9 + operação. Retorna {id, question}.
-- Operações: + - * (resultado sempre positivo)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.generate_math_challenge()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_a integer;
v_b integer;
v_op text;
v_ans integer;
v_q text;
v_id uuid;
BEGIN
v_a := 1 + floor(random() * 9)::int;
v_b := 1 + floor(random() * 9)::int;
v_op := (ARRAY['+','-','*'])[1 + floor(random() * 3)::int];
-- garantir resultado positivo na subtração
IF v_op = '-' AND v_b > v_a THEN
v_a := v_a + v_b;
END IF;
v_ans := CASE v_op
WHEN '+' THEN v_a + v_b
WHEN '-' THEN v_a - v_b
WHEN '*' THEN v_a * v_b
END;
v_q := format('Quanto é %s %s %s?', v_a, v_op, v_b);
INSERT INTO math_challenges (question, answer)
VALUES (v_q, v_ans)
RETURNING id INTO v_id;
RETURN jsonb_build_object('id', v_id, 'question', v_q);
END;
$function$;
REVOKE ALL ON FUNCTION public.generate_math_challenge() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.generate_math_challenge() TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- verify_math_challenge
-- Valida {id, answer}. Marca used. Bloqueia uso duplicado.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.verify_math_challenge(
p_id uuid,
p_answer integer
)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
mc math_challenges%ROWTYPE;
BEGIN
IF p_id IS NULL OR p_answer IS NULL THEN RETURN false; END IF;
SELECT * INTO mc FROM math_challenges WHERE id = p_id;
IF NOT FOUND OR mc.used OR mc.expires_at < now() THEN
RETURN false;
END IF;
UPDATE math_challenges SET used = true WHERE id = p_id;
RETURN mc.answer = p_answer;
END;
$function$;
REVOKE ALL ON FUNCTION public.verify_math_challenge(uuid, integer) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.verify_math_challenge(uuid, integer) TO service_role;
-- ─────────────────────────────────────────────────────────────────────────
-- cleanup_expired_math_challenges (chamável via cron)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cleanup_expired_math_challenges()
RETURNS integer
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
WITH d AS (
DELETE FROM math_challenges WHERE expires_at < now() - interval '1 hour' RETURNING 1
)
SELECT COUNT(*)::int FROM d;
$function$;
REVOKE ALL ON FUNCTION public.cleanup_expired_math_challenges() FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.cleanup_expired_math_challenges() TO service_role;