-- ============================================================================= -- 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;