7c20b518d4
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>
300 lines
12 KiB
PL/PgSQL
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;
|