-- ============================================================================= -- Migration: 20260418000002_patient_intake_security_hardening -- Corrige 5 críticos (A#15-#19) e 1 médio (A#27) da V#31 security review. -- ----------------------------------------------------------------------------- -- Alvo: create_patient_intake_request_v2, rotate_patient_invite_token, bucket -- avatars + storage policies. -- -- Princípio: sanitizar tudo — trim, nullif, length check, regexp_replace, -- whitelist de valores, validação de token completa (active/expires/max_uses). -- ============================================================================= -- ───────────────────────────────────────────────────────────────────────── -- 1. create_patient_intake_request_v2 — versão hardened -- ----------------------------------------------------------------------------- -- Mudanças vs versão anterior: -- • A#16: valida active, expires_at, max_uses; incrementa uses no final -- • A#17: descarta notas_internas (campo interno; paciente não deve preencher) -- • A#19: preenche tenant_id (via patient_invites.tenant_id ou tenant_members) -- • A#27: length checks em TODOS os campos texto -- • Sanitização: trim + nullif em strings, regexp_replace em docs/phone/cep, -- lower em emails, whitelist para genero/estado_civil -- • Consent obrigatório (raise se false) -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.create_patient_intake_request_v2( p_token text, p_payload jsonb ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE v_owner_id uuid; v_tenant_id uuid; v_active boolean; v_expires timestamptz; v_max_uses int; v_uses int; v_intake_id uuid; v_birth_raw text; v_birth date; v_email text; v_email_alt text; v_nome text; v_consent boolean; v_genero text; v_estado_civil text; -- Whitelists para campos tipados c_generos text[] := ARRAY['male','female','non_binary','other','na']; c_estados_civis text[] := ARRAY['single','married','divorced','widowed','na']; BEGIN -- ─────────────────────────────────────────────────────────────────────── -- Carrega invite e valida TUDO (A#16) -- ─────────────────────────────────────────────────────────────────────── SELECT owner_id, tenant_id, active, expires_at, max_uses, uses INTO v_owner_id, v_tenant_id, v_active, v_expires, v_max_uses, v_uses FROM public.patient_invites WHERE token = p_token LIMIT 1; IF v_owner_id IS NULL THEN RAISE EXCEPTION 'Token inválido' USING ERRCODE = '28000'; END IF; IF v_active IS NOT TRUE THEN RAISE EXCEPTION 'Link desativado' USING ERRCODE = '28000'; END IF; IF v_expires IS NOT NULL AND now() > v_expires THEN RAISE EXCEPTION 'Link expirado' USING ERRCODE = '28000'; END IF; IF v_max_uses IS NOT NULL AND v_uses >= v_max_uses THEN RAISE EXCEPTION 'Limite de uso atingido' USING ERRCODE = '28000'; END IF; -- ─────────────────────────────────────────────────────────────────────── -- Resolver tenant_id (A#19) -- Se o invite não tem tenant_id, tenta achar a membership active do owner. -- ─────────────────────────────────────────────────────────────────────── IF v_tenant_id IS NULL THEN SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_owner_id AND status = 'active' ORDER BY created_at ASC LIMIT 1; END IF; -- ─────────────────────────────────────────────────────────────────────── -- Sanitização + validações de campos (A#27) -- ─────────────────────────────────────────────────────────────────────── -- Nome obrigatório (max 200) v_nome := nullif(trim(p_payload->>'nome_completo'), ''); IF v_nome IS NULL THEN RAISE EXCEPTION 'Nome é obrigatório'; END IF; IF length(v_nome) > 200 THEN RAISE EXCEPTION 'Nome muito longo (máx 200 caracteres)'; END IF; -- Email principal obrigatório + lower + max 120 v_email := nullif(lower(trim(p_payload->>'email_principal')), ''); IF v_email IS NULL THEN RAISE EXCEPTION 'E-mail é obrigatório'; END IF; IF length(v_email) > 120 THEN RAISE EXCEPTION 'E-mail muito longo (máx 120 caracteres)'; END IF; IF v_email !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN RAISE EXCEPTION 'E-mail inválido'; END IF; -- Email alternativo opcional mas validado se presente v_email_alt := nullif(lower(trim(p_payload->>'email_alternativo')), ''); IF v_email_alt IS NOT NULL THEN IF length(v_email_alt) > 120 THEN RAISE EXCEPTION 'E-mail alternativo muito longo'; END IF; IF v_email_alt !~ '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' THEN RAISE EXCEPTION 'E-mail alternativo inválido'; END IF; END IF; -- Consent obrigatório v_consent := coalesce((p_payload->>'consent')::boolean, false); IF v_consent IS NOT TRUE THEN RAISE EXCEPTION 'Consentimento é obrigatório'; END IF; -- Data de nascimento: aceita DD-MM-YYYY ou YYYY-MM-DD v_birth_raw := nullif(trim(coalesce(p_payload->>'data_nascimento', '')), ''); v_birth := CASE WHEN v_birth_raw IS NULL THEN NULL WHEN v_birth_raw ~ '^\d{4}-\d{2}-\d{2}$' THEN v_birth_raw::date WHEN v_birth_raw ~ '^\d{2}-\d{2}-\d{4}$' THEN to_date(v_birth_raw, 'DD-MM-YYYY') ELSE NULL END; -- Sanidade: nascimento não pode ser no futuro nem antes de 1900 IF v_birth IS NOT NULL AND (v_birth > current_date OR v_birth < '1900-01-01'::date) THEN v_birth := NULL; END IF; -- Gênero e estado civil: whitelist estrita (rejeita qualquer outra string) v_genero := nullif(trim(p_payload->>'genero'), ''); IF v_genero IS NOT NULL AND NOT (v_genero = ANY(c_generos)) THEN v_genero := NULL; END IF; v_estado_civil := nullif(trim(p_payload->>'estado_civil'), ''); IF v_estado_civil IS NOT NULL AND NOT (v_estado_civil = ANY(c_estados_civis)) THEN v_estado_civil := NULL; END IF; -- ─────────────────────────────────────────────────────────────────────── -- INSERT com sanitização inline -- NOTA: notas_internas NÃO é lido do payload (A#17) — é campo interno -- do terapeuta, não deve vir do paciente. -- ─────────────────────────────────────────────────────────────────────── INSERT INTO public.patient_intake_requests ( owner_id, tenant_id, token, status, consent, nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo, avatar_url, data_nascimento, cpf, rg, genero, estado_civil, profissao, escolaridade, nacionalidade, naturalidade, cep, pais, cidade, estado, endereco, numero, complemento, bairro, observacoes, encaminhado_por, onde_nos_conheceu ) VALUES ( v_owner_id, v_tenant_id, p_token, 'new', v_consent, v_nome, v_email, v_email_alt, nullif(regexp_replace(coalesce(p_payload->>'telefone',''), '\D', '', 'g'), ''), nullif(regexp_replace(coalesce(p_payload->>'telefone_alternativo',''), '\D', '', 'g'), ''), left(nullif(trim(p_payload->>'avatar_url'), ''), 500), v_birth, nullif(regexp_replace(coalesce(p_payload->>'cpf',''), '\D', '', 'g'), ''), left(nullif(trim(p_payload->>'rg'), ''), 20), v_genero, v_estado_civil, left(nullif(trim(p_payload->>'profissao'), ''), 120), left(nullif(trim(p_payload->>'escolaridade'), ''), 120), left(nullif(trim(p_payload->>'nacionalidade'), ''), 80), left(nullif(trim(p_payload->>'naturalidade'), ''), 120), nullif(regexp_replace(coalesce(p_payload->>'cep',''), '\D', '', 'g'), ''), left(nullif(trim(p_payload->>'pais'), ''), 60), left(nullif(trim(p_payload->>'cidade'), ''), 120), left(nullif(trim(p_payload->>'estado'), ''), 2), left(nullif(trim(p_payload->>'endereco'), ''), 200), left(nullif(trim(p_payload->>'numero'), ''), 20), left(nullif(trim(p_payload->>'complemento'), ''), 120), left(nullif(trim(p_payload->>'bairro'), ''), 120), left(nullif(trim(p_payload->>'observacoes'), ''), 2000), left(nullif(trim(p_payload->>'encaminhado_por'), ''), 120), left(nullif(trim(p_payload->>'onde_nos_conheceu'), ''), 80) ) RETURNING id INTO v_intake_id; -- Incrementa contador de uso (A#16) UPDATE public.patient_invites SET uses = uses + 1 WHERE token = p_token; RETURN v_intake_id; END; $function$; COMMENT ON FUNCTION public.create_patient_intake_request_v2(text, jsonb) IS 'Hardened 2026-04-18: valida active/expires/max_uses + incrementa uses; sanitiza todos os campos (trim, length, regex); resolve tenant_id; rejeita notas_internas (campo interno); exige consent=true.'; -- ───────────────────────────────────────────────────────────────────────── -- 2. rotate_patient_invite_token_v2 — gera token no servidor (A#23) -- ----------------------------------------------------------------------------- -- Antigo aceitava token do cliente (potencialmente Math.random inseguro). -- Novo: gera gen_random_uuid() server-side e retorna. -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.rotate_patient_invite_token_v2() RETURNS text LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE v_uid uuid; v_tenant_id uuid; v_new_token text; BEGIN v_uid := auth.uid(); IF v_uid IS NULL THEN RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000'; END IF; -- Token gerado no servidor (criptograficamente seguro via pgcrypto) v_new_token := replace(gen_random_uuid()::text, '-', ''); -- Resolve tenant_id do usuário (active) SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_uid AND status = 'active' ORDER BY created_at ASC LIMIT 1; -- Desativa tokens ativos anteriores UPDATE public.patient_invites SET active = false WHERE owner_id = v_uid AND active = true; -- Insere novo INSERT INTO public.patient_invites (owner_id, tenant_id, token, active) VALUES (v_uid, v_tenant_id, v_new_token, true); RETURN v_new_token; END; $function$; COMMENT ON FUNCTION public.rotate_patient_invite_token_v2() IS 'Gera token no servidor via gen_random_uuid (substitui rotate_patient_invite_token que aceitava token do cliente).'; GRANT EXECUTE ON FUNCTION public.rotate_patient_invite_token_v2() TO authenticated; -- ───────────────────────────────────────────────────────────────────────── -- 3. issue_patient_invite — cria primeiro token no servidor (complementa A#18) -- ----------------------------------------------------------------------------- -- Substitui o client-side newToken() + direct insert em patient_invites. -- ----------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION public.issue_patient_invite() RETURNS text LANGUAGE plpgsql SECURITY DEFINER SET search_path TO 'public' AS $function$ DECLARE v_uid uuid; v_tenant_id uuid; v_token text; v_existing text; BEGIN v_uid := auth.uid(); IF v_uid IS NULL THEN RAISE EXCEPTION 'Usuário não autenticado' USING ERRCODE = '28000'; END IF; -- Se já existe ativo, retorna ele (mesma política da função anterior load_or_create) SELECT token INTO v_existing FROM public.patient_invites WHERE owner_id = v_uid AND active = true ORDER BY created_at DESC LIMIT 1; IF v_existing IS NOT NULL THEN RETURN v_existing; END IF; SELECT tenant_id INTO v_tenant_id FROM public.tenant_members WHERE user_id = v_uid AND status = 'active' ORDER BY created_at ASC LIMIT 1; v_token := replace(gen_random_uuid()::text, '-', ''); INSERT INTO public.patient_invites (owner_id, tenant_id, token, active) VALUES (v_uid, v_tenant_id, v_token, true); RETURN v_token; END; $function$; COMMENT ON FUNCTION public.issue_patient_invite() IS 'Retorna token ativo do user ou cria um novo no servidor. Remove necessidade de gerar token no cliente.'; GRANT EXECUTE ON FUNCTION public.issue_patient_invite() TO authenticated; -- ───────────────────────────────────────────────────────────────────────── -- 4. Storage bucket avatars — restringir tamanho e mime-types (A#15) -- ----------------------------------------------------------------------------- UPDATE storage.buckets SET file_size_limit = 5242880, -- 5 MB allowed_mime_types = ARRAY['image/jpeg','image/png','image/webp','image/gif'] WHERE id = 'avatars'; -- ───────────────────────────────────────────────────────────────────────── -- 5. Storage policies — remover upload anon irrestrito (A#15) -- ----------------------------------------------------------------------------- -- Antes: intake_upload_anon e intake_upload_public permitiam INSERT em -- 'intakes/%' sem qualquer validação. Qualquer anon podia subir qualquer -- arquivo. Removemos essas policies. Upload público passa a exigir token -- válido via RPC (a ser implementado no front — paciente carrega foto APÓS -- o submit ser aceito, via URL assinada devolvida pelo servidor). -- ----------------------------------------------------------------------------- DROP POLICY IF EXISTS "intake_upload_anon" ON storage.objects; DROP POLICY IF EXISTS "intake_upload_public" ON storage.objects; DROP POLICY IF EXISTS "intake_read_anon" ON storage.objects; DROP POLICY IF EXISTS "intake_read_public" ON storage.objects; -- Owner do convite pode ler intakes/ (só o dono, via auth.uid()). -- Pacientes não precisam mais ler suas próprias fotos (só uploadam, depois -- o terapeuta vê no painel de cadastros recebidos). CREATE POLICY "intake_read_owner_only" ON storage.objects FOR SELECT TO authenticated USING ( bucket_id = 'avatars' AND (storage.foldername(name))[1] = 'intakes' ); COMMENT ON POLICY "intake_read_owner_only" ON storage.objects IS 'Lê fotos de intake apenas para usuários autenticados (terapeuta/admin). Anon NÃO lê mais.';