Ejemplo · no es un cliente
Informe de ejemplo sobre nuestra app demo — no es un cliente. turnos-pro es un SaaS de turnos ficticio que armamos con Lovable + Supabase para probar nuestro proceso. Cada número de este informe sale de pruebas reales sobre esa app.

Ronda 1 + verificación de correcciones

Informe de preparación para producción

App
turnos-pro · SaaS de turnos para clínicas y peluquerías (demo)
Stack
Lovable · React + Vite · Supabase (Postgres, Auth, Storage, Edge Functions) · Mercado Pago
Fecha
13 de octubre de 2026
Método
OWASP ASVS 5.0 · revisión de código + pruebas de ataque automatizadas

Vista para el fundador

Veredicto
NO LISTALISTA PARA LOS CHEQUEOS FINALES DE SALIDA

Tal como está, la app no debería lanzarse: 6 problemas críticos exponen todos los datos de clientes y permiten pagos falsos. Con las correcciones de este informe aplicadas (ya escritas y probadas) y los pasos manuales de "Próximas 24 horas" hechos, todos los controles que corrimos pasan. Antes de lanzar quedan los chequeos de salida que esta revisión no cubrió: una prueba de pago en vivo, la configuración de Supabase Auth, backups, hosting y dependencias (ver Alcance).

53controles corridos38 de base de datos · 9 de edge functions · 6 de código
11pasan antes
53pasan después
14hallazgos6 críticos · 3 altos · 5 medios

Qué muestran los controles

Ataques bloqueados39 intentos de ataque como visitante anónimo, usuario externo y otro negocio
Antes
0/39
Después
39/39
El uso normal sigue funcionando14 chequeos de que los usuarios legítimos pueden seguir trabajando
Antes
11/14
Después
14/14

Resumen para el fundador

¿Qué impide lanzar?

Seis problemas críticos, todos corregidos en este informe: la llave maestra de la base está dentro del sitio (F-01); cualquiera puede sumarse como dueño de cualquier negocio (F-02) o hacerse admin (F-03); los pagos se falsifican con un solo pedido (F-04); la agenda de cualquier negocio y todas las carteras de clientes las puede leer un desconocido (F-05, F-06).

¿Qué podría exponer datos de clientes?

Antes de corregir: nombres, emails, teléfonos y notas privadas de clientes, incluidos datos de salud (F-06), teléfonos de clientes vía la agenda sin login (F-05), DNI y documentos médicos en storage (F-08), y el email y teléfono de todos los usuarios (F-10). Después de corregir, cada uno de esos ataques se volvió a correr y no devolvió nada.

¿El pago es lo bastante seguro para lanzar?

Antes no: un pedido falso marcó un turno como pagado (control C39), el cliente elegía el precio (C42) y los avisos reales de Mercado Pago ni siquiera se procesaban (C40). Después de corregir: los pedidos sin firma se rechazan, los montos se confirman con Mercado Pago y tienen que coincidir con el precio del turno, y un aviso repetido se registra una sola vez. Condición: cargar los dos secretos de Mercado Pago y completar un pago de punta a punta en el sandbox antes de salir — esa prueba en vivo queda fuera de esta revisión.

Próximas 24 horas

  1. Rotar la clave service_role en Supabase y borrar el .env del historial de git (F-01).
  2. Aplicar la migración 0005_security_fixes.sql y desplegar las cuatro edge functions actualizadas.
  3. Cargar los secretos de las funciones: MP_WEBHOOK_SECRET, MP_ACCESS_TOKEN, ALLOWED_ORIGINS.
  4. Revisar datos de producción: filas inesperadas en staff, cuentas con is_admin = true, reservas activas superpuestas.

Próximos 7 días

  1. Hacer un pago de punta a punta en el sandbox de Mercado Pago y confirmar que el turno pasa a confirmado.
  2. Sumar tests/run-controls.sh al CI para que cada cambio de Lovable se vuelva a probar antes de desplegar.
  3. Desactivar el cupón AMIGO50 si no debe seguir siendo público.
  4. Definir el flujo de reserva pública: hoy un visitante anónimo no puede cargar /book/:slug (los negocios solo los leen sus miembros), así que un cliente no puede reservar sin ser staff. Es una falla funcional, no de seguridad.

Hallazgos de un vistazo

IDGravedadQué significa para vosEstado
F-01 Crítica La llave maestra de la base de datos está publicada dentro del sitio webCualquiera que abra el sitio puede copiar la llave que se saltea todas las reglas de seguridad y leer, cambiar o borrar todos los datos de todos los negocios. CorregidoC48, C46
F-02 Crítica Cualquier usuario registrado puede agregarse como dueño de cualquier negocioCon una cuenta gratis alcanza para sumarse a cualquier clínica o peluquería como staff y desde ahí ver y cambiar su agenda, clientes, pagos, precios y documentos. CorregidoC07
F-03 Crítica Cualquier usuario puede hacerse administradorCon un solo pedido una cuenta normal pasa a ser admin. Hoy eso habilita el menú de admin; cualquier función de admin que se agregue después quedaría abierta para todos. CorregidoC01, C02
F-04 Crítica Los pagos se pueden falsificar: el webhook de pagos le cree a cualquier pedidoCualquiera puede mandar un pedido que marca un turno como pagado y confirmado sin pagar, o llenar la facturación de un negocio con pagos inventados. CorregidoC39, C40, C30, C31
F-05 Crítica La agenda de cualquier negocio se puede descargar sin iniciar sesiónSin cuenta, cualquiera puede bajar todos los turnos de cualquier negocio, con nombre y teléfono de cada cliente, servicio y precio. CorregidoC12, C13
F-06 Crítica Todos los clientes de todos los negocios son visibles para cualquier usuario logueadoCualquiera que se registre puede bajar la cartera completa de clientes de todas las clínicas y peluquerías: nombre, email, teléfono y notas privadas, que pueden incluir datos de salud. CorregidoC09, C10
F-07 Alta Cualquiera, incluso sin login, puede cancelar o confirmar cualquier turnoUn desconocido puede cancelar todo el día de un negocio o marcar turnos impagos como confirmados. Los ids de turno quedan expuestos por F-05. CorregidoC15, C16, C17
F-08 Alta Los documentos de identidad de clientes se pueden leer y subir entre negociosCualquier usuario logueado puede listar y descargar DNI, estudios médicos y consentimientos de los clientes de todos los negocios, y meter archivos en carpetas de otros negocios. CorregidoC19, C21, C28
F-09 Alta El cliente decide el precio, y los códigos de descuento se ven en el sitioUna reserva se puede mandar con cualquier precio (por ejemplo 1 centavo). El código AMIGO50 de 50% y los otros cupones los puede leer cualquiera en el código de la página. CorregidoC42, C32, C51
F-10 Media El email y teléfono de todos los usuarios se pueden listar sin loginCualquiera en internet puede bajar la lista de usuarios registrados con email, teléfono y quién es admin: material para spam, phishing o ataques dirigidos. CorregidoC04
F-11 Media Cualquiera puede alojar archivos en el bucket público de marketingCualquier cuenta puede subir una página de phishing o malware que queda servida desde la dirección pública de storage de tu proyecto, sin límite de tamaño ni de tipo. CorregidoC24, C29
F-12 Media Se pueden hacer reservas superpuestas y en el pasadoDos clientes pueden quedarse con el mismo horario, y la página de reservas ofrece horarios que ya pasaron. Molesto para los clientes, costoso para el negocio. CorregidoC35, C36
F-13 Media Las respuestas de error muestran detalles internosUn pedido mal formado devuelve stack traces y mensajes crudos de la base que le ayudan a un atacante a mapear el sistema. CorregidoC41, C43
F-14 Media Cualquier sitio web puede llamar a las funciones desde el navegador de un visitanteLas funciones aceptan llamadas cross-origin de cualquier sitio, y una además declara que acepta credenciales, una combinación inválida e insegura. CorregidoC44

Hallazgos técnicos

F-01 Crítica

La llave maestra de la base de datos está publicada dentro del sitio web

Corregido

Impacto. Cualquiera que abra el sitio puede copiar la llave que se saltea todas las reglas de seguridad y leer, cambiar o borrar todos los datos de todos los negocios.

Ubicación
src/integrations/supabase/admin.ts · .env (VITE_SUPABASE_SERVICE_ROLE_KEY) · src/pages/AdminPage.tsx
Estándar
V13 Configuración (gestión de secretos) · V8 Autorización

Qué está mal

El panel de admin crea un cliente de Supabase con la clave service_role leída de una variable VITE_. Vite incrusta toda variable VITE_ en el JavaScript público, así que la clave le llega a cada visitante. El .env que la contiene está commiteado. La ruta /admin no tiene ningún chequeo en el servidor; solo se oculta el link del menú.

Evidencia (salida real de las pruebas)

Build de producción de la app original (npx vite build) y búsqueda en el resultado:
$ grep -o 'eyJ[A-Za-z0-9._-]*service_role_key_full_access' dist/assets/*.js
dist/assets/index-D4vCh4Ci.js: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIi...
# El payload del JWT es {"role":"service_role"}. Misma búsqueda en el build corregido: sin resultados.

C48 Ninguna clave service_role en el frontend ni en variables VITE_; .env ignorado por git

antesFALLAservice_role referenced in: src/integrations/supabase/admin.ts, .env; .env not in .gitignore
despuésPASAno references; .env ignored

C46 Los datos de admin se niegan a un usuario no admin (chequeo en servidor)

antesFALLAno server-side admin endpoint: the admin panel queries the database from the browser with the service_role key
despuésPASAHTTP 403 {"error":"forbidden"}

Cómo reproducirlo

# 1. Open the live site, DevTools > Sources, search the main .js file for "service_role"
# 2. Use the key against the REST API — every row of every table comes back:
curl "https://<project>.supabase.co/rest/v1/clients?select=*" \
  -H "apikey: <service_role key from the bundle>" \
  -H "Authorization: Bearer <service_role key from the bundle>"

Comportamiento esperado

La clave service_role existe solo en el servidor. Los datos de admin los entrega una función de servidor que verifica que quien llama sea admin (un dato que el usuario no puede editar).

Corrección (extracto del diff)

- src/integrations/supabase/admin.ts          (deleted)
- VITE_SUPABASE_SERVICE_ROLE_KEY=eyJ...        (.env, removed; .env added to .gitignore)
--- a/src/pages/AdminPage.tsx
-      const { data } = await adminClient.from("businesses").select("id, name, slug, owner_id");
+      const { data, error } = await supabase.functions.invoke("admin-overview");
+++ b/supabase/functions/admin-overview/index.ts  (new)
+  const { data: { user } } = await admin.auth.getUser(token);
+  if (!user) return json({ error: "unauthorized" }, 401);
+  const { data: profile } = await admin.from("profiles").select("is_admin").eq("id", user.id).single();
+  if (!profile?.is_admin) return json({ error: "forbidden" }, 403);
También hace falta (manual)

Rotar la clave service_role en Supabase (Settings > API) y borrar el .env del historial de git. Eso no se resuelve con código: la clave vieja hay que darla por filtrada.

Controles de este hallazgo

ControlAntesDespués
C48 Ninguna clave service_role en el frontend ni en variables VITE_; .env ignorado por git FALLA PASA
C46 Los datos de admin se niegan a un usuario no admin (chequeo en servidor) FALLA PASA
C47 Un admin real recibe los datos a través del servidor uso normal FALLA PASA
F-02 Crítica

Cualquier usuario registrado puede agregarse como dueño de cualquier negocio

Corregido

Impacto. Con una cuenta gratis alcanza para sumarse a cualquier clínica o peluquería como staff y desde ahí ver y cambiar su agenda, clientes, pagos, precios y documentos.

Ubicación
supabase/migrations/0002_rls.sql · policy "staff can be added" on public.staff
Estándar
V8 Autorización

Qué está mal

La política de INSERT sobre staff es WITH CHECK (true). Todos los demás permisos de la app preguntan "¿este usuario está en staff de este negocio?", así que quien puede escribir en staff tiene la llave de todos los negocios. Los IDs de negocio se leen sin login desde la tabla pública de servicios.

Evidencia (salida real de las pruebas)

C07 Un externo no puede agregarse como dueño de otro negocio

antesFALLAaffected=1; state after = 1
despuésPASAERROR: new row violates row-level security policy for table "staff"

Cómo reproducirlo

-- as any logged-in user (role authenticated, own JWT)
insert into public.staff (business_id, user_id, role, display_name)
values ('<business A id>', auth.uid(), 'owner', 'x');

Comportamiento esperado

Solo el dueño de un negocio puede agregarle staff.

Corrección (extracto del diff)

-create policy "staff can be added" on public.staff
-  for insert to authenticated
-  with check (true);
+create policy "owners add staff" on public.staff
+  for insert to authenticated
+  with check (public.is_business_owner(business_id));
También hace falta (manual)

Revisar la tabla staff en producción y borrar membresías que nadie aprobó.

Controles de este hallazgo

ControlAntesDespués
C07 Un externo no puede agregarse como dueño de otro negocio FALLA PASA
C08 El dueño puede agregar staff a su propio negocio uso normal PASA PASA
F-03 Crítica

Cualquier usuario puede hacerse administrador

Corregido

Impacto. Con un solo pedido una cuenta normal pasa a ser admin. Hoy eso habilita el menú de admin; cualquier función de admin que se agregue después quedaría abierta para todos.

Ubicación
supabase/migrations/0002_rls.sql · policy "users update own profile" · src/components/Layout.tsx
Estándar
V8 Autorización (acceso a nivel de campo)

Qué está mal

La política de UPDATE de perfiles limita bien al usuario a su propia fila, pero no a ciertas columnas. is_admin y role están en esa misma fila, así que el usuario puede reescribirlos.

Evidencia (salida real de las pruebas)

C01 Un usuario no puede darse is_admin = true

antesFALLAaffected=1; state after = true
despuésPASAERROR: permission denied for table profiles

C02 Un usuario no puede cambiar su propio rol a admin

antesFALLAaffected=1; state after = admin
despuésPASAERROR: permission denied for table profiles

Cómo reproducirlo

-- as any logged-in user
update public.profiles set is_admin = true, role = 'admin' where id = auth.uid();

Comportamiento esperado

El usuario puede editar su nombre y teléfono; role e is_admin solo cambian desde el servidor.

Corrección (extracto del diff)

+revoke insert, update on public.profiles from anon, authenticated;
+grant update (full_name, phone) on public.profiles to authenticated;
También hace falta (manual)

Revisar qué cuentas tienen hoy is_admin = true o role = 'admin' en producción.

Controles de este hallazgo

ControlAntesDespués
C01 Un usuario no puede darse is_admin = true FALLA PASA
C02 Un usuario no puede cambiar su propio rol a admin FALLA PASA
C03 El usuario puede seguir editando su nombre y teléfono uso normal PASA PASA
F-04 Crítica

Los pagos se pueden falsificar: el webhook de pagos le cree a cualquier pedido

Corregido

Impacto. Cualquiera puede mandar un pedido que marca un turno como pagado y confirmado sin pagar, o llenar la facturación de un negocio con pagos inventados.

Ubicación
supabase/functions/payments-webhook/index.ts · 0002_rls.sql policy "members insert payments"
Estándar
V2 Validación y lógica de negocio · V4 API y servicios web

Qué está mal

El webhook corre con la clave service_role y toma estado, monto, turno y negocio del cuerpo del pedido, sin verificar la firma de Mercado Pago ni consultar el pago a Mercado Pago. Si no viene estado, asume "approved". Además, el staff podía insertar pagos "paid" directo desde el navegador, y nada impedía registrar el mismo pago dos veces. La prueba mostró también el problema inverso: un aviso real de Mercado Pago (que solo trae el id del pago) se rechazaba con "missing appointment", así que los pagos reales nunca confirmaban turnos.

Evidencia (salida real de las pruebas)

C39 Un aviso falso y sin firma de "pago aprobado" no cambia nada

antesFALLAHTTP 200; database writes: POST /rest/v1/payments {"appointment_id":"a1000000-0000-0000-0000-000000000001","business_id":"aaaaaaaa-0000-0000-0000-000000000001","amount_cents":100,"status":"paid","provider":"mp","provider_ref":""} || PATCH /rest/v1/appointments {"status":"confirmed"}
despuésPASAHTTP 401; database writes: none

C40 Un aviso real firmado (confirmado con Mercado Pago) marca el turno como pagadouso normal

antesFALLAHTTP 400 {"error":"missing appointment"}; database writes: none
despuésPASAHTTP 200 {"ok":true}; database writes: POST /rest/v1/payments {"appointment_id":"a1000000-0000-0000-0000-000000000001","business_id":"aaaaaaaa-0000-0000-0000-000000000001","amount_cents":500000,"currency":"ARS","status":"paid","provider":"mp","provider_ref":" || PATCH /rest/v1/appointments {"status":"confirmed"}

C30 Nadie puede registrar un pago "paid" desde el navegador (solo el webhook verificado)

antesFALLAaffected=1; state after = 1
despuésPASAERROR: new row violates row-level security policy for table "payments"

C31 Un webhook repetido no puede registrar el mismo pago dos veces

antesFALLAaffected=2; state after = 2
despuésPASAERROR: duplicate key value violates unique constraint "payments_provider_ref_unique"

Cómo reproducirlo

curl -X POST "https://<project>.supabase.co/functions/v1/payments-webhook" \
  -H "Content-Type: application/json" \
  -d '{"metadata":{"appointment_id":"<id>","business_id":"<id>"},"amount":1}'
# -> 200 {"ok":true}; payment stored as "paid", appointment set to "confirmed"

Comportamiento esperado

Los pedidos sin firma reciben 401 y no cambian nada. Estado y monto salen de la API de Mercado Pago, el monto tiene que coincidir con el precio del turno, y cada pago se registra una sola vez.

Corrección (extracto del diff)

-    const status = event.data?.status ?? event.status ?? "approved";
-    const amount = event.data?.transaction_amount ?? event.amount ?? 0;
+    const valid = await verifyMpSignature({
+      xSignature: req.headers.get("x-signature"),
+      xRequestId: req.headers.get("x-request-id"),
+      dataId, secret: Deno.env.get("MP_WEBHOOK_SECRET") ?? "" });
+    if (!valid) return json({ error: "invalid_signature" }, 401);
+    const mpRes = await fetch(`https://api.mercadopago.com/v1/payments/${encodeURIComponent(dataId!)}`, ...);
+    const paid = payment.status === "approved" && amountCents === appt.price_cents;
+    await supabase.from("payments").upsert({ ... }, { onConflict: "provider,provider_ref" });
-- migration 0005
+drop policy if exists "members insert payments" on public.payments;
+alter table public.payments add constraint payments_provider_ref_unique unique (provider, provider_ref);
También hace falta (manual)

Cargar MP_WEBHOOK_SECRET y MP_ACCESS_TOKEN como secretos de la función y hacer un pago de punta a punta en el sandbox de Mercado Pago antes de lanzar.

Controles de este hallazgo

ControlAntesDespués
C39 Un aviso falso y sin firma de "pago aprobado" no cambia nada FALLA PASA
C40 Un aviso real firmado (confirmado con Mercado Pago) marca el turno como pagado uso normal FALLA PASA
C49 El verificador de firma acepta webhooks reales y rechaza falsos o repetidos (ejecutado) FALLA PASA
C50 Estado y monto del pago se leen de Mercado Pago y se comparan con el precio del turno FALLA PASA
C30 Nadie puede registrar un pago "paid" desde el navegador (solo el webhook verificado) FALLA PASA
C31 Un webhook repetido no puede registrar el mismo pago dos veces FALLA PASA
F-05 Crítica

La agenda de cualquier negocio se puede descargar sin iniciar sesión

Corregido

Impacto. Sin cuenta, cualquiera puede bajar todos los turnos de cualquier negocio, con nombre y teléfono de cada cliente, servicio y precio.

Ubicación
supabase/migrations/0003_functions.sql · function public.get_business_appointments
Estándar
V8 Autorización

Qué está mal

La función es SECURITY DEFINER (se saltea la seguridad por filas) y filtra solo por el id de negocio que recibe, sin verificar que quien llama pertenezca a ese negocio. La puede ejecutar anon. Los escáneres gratuitos no la marcan porque tiene search_path fijo.

Evidencia (salida real de las pruebas)

C12 Un visitante sin login no puede bajar la agenda de un negocio

antesFALLArows=1: ("Patient A","+54 11 4444-0001",500000)
despuésPASAERROR: permission denied for function get_business_appointments

C13 El negocio B no puede bajar la agenda del negocio A

antesFALLArows=1: ("Patient A","+54 11 4444-0001",500000)
despuésPASAERROR: forbidden

Cómo reproducirlo

curl "https://<project>.supabase.co/rest/v1/rpc/get_business_appointments" \
  -H "apikey: <public anon key>" -H "Content-Type: application/json" \
  -d '{"p_business_id":"<business A id>"}'

Comportamiento esperado

Solo los miembros del negocio pueden leer su agenda; las llamadas anónimas se rechazan.

Corrección (extracto del diff)

+  if not public.is_business_member(p_business_id) then
+    raise exception 'forbidden' using errcode = '42501';
+  end if;
+revoke execute on function public.get_business_appointments(uuid, timestamptz, timestamptz) from public, anon;

Controles de este hallazgo

ControlAntesDespués
C12 Un visitante sin login no puede bajar la agenda de un negocio FALLA PASA
C13 El negocio B no puede bajar la agenda del negocio A FALLA PASA
C14 El negocio A puede ver su propia agenda uso normal PASA PASA
F-06 Crítica

Todos los clientes de todos los negocios son visibles para cualquier usuario logueado

Corregido

Impacto. Cualquiera que se registre puede bajar la cartera completa de clientes de todas las clínicas y peluquerías: nombre, email, teléfono y notas privadas, que pueden incluir datos de salud.

Ubicación
supabase/migrations/0002_rls.sql · policy "read clients" on public.clients
Estándar
V8 Autorización · V14 Protección de datos

Qué está mal

La política de SELECT es USING (auth.role() = 'authenticated'), que es verdadero para cualquier usuario logueado. Las otras políticas de clients chequean bien la membresía; esta no.

Evidencia (salida real de las pruebas)

C09 Un externo no puede leer clientes de ningún negocio

antesFALLArows=2: ("Patient A",patient@a.test,"+54 11 4444-0001","allergic to penicillin") | ("Customer B",customer@b.test,"+54 11 4444-0002","prefers mornings")
despuésPASArows=0

C10 El negocio B no puede leer los clientes del negocio A

antesFALLArows=1: ("Patient A","+54 11 4444-0001","allergic to penicillin")
despuésPASArows=0

Cómo reproducirlo

-- as any logged-in user
select full_name, email, phone, notes from public.clients;

Comportamiento esperado

Cada usuario ve solo los clientes de los negocios a los que pertenece.

Corrección (extracto del diff)

-create policy "read clients" on public.clients
-  for select to authenticated
-  using (auth.role() = 'authenticated');
+create policy "members read clients" on public.clients
+  for select to authenticated
+  using (public.is_business_member(business_id));

Controles de este hallazgo

ControlAntesDespués
C09 Un externo no puede leer clientes de ningún negocio FALLA PASA
C10 El negocio B no puede leer los clientes del negocio A FALLA PASA
C11 El negocio A puede leer sus propios clientes uso normal PASA PASA
F-07 Alta

Cualquiera, incluso sin login, puede cancelar o confirmar cualquier turno

Corregido

Impacto. Un desconocido puede cancelar todo el día de un negocio o marcar turnos impagos como confirmados. Los ids de turno quedan expuestos por F-05.

Ubicación
supabase/migrations/0003_functions.sql · function public.update_appointment_status
Estándar
V8 Autorización · V2 Validación y lógica de negocio

Qué está mal

Función SECURITY DEFINER que actualiza cualquier turno por id, sin chequear membresía ni validar el nuevo estado. La puede ejecutar anon.

Evidencia (salida real de las pruebas)

C15 Un visitante sin login no puede cancelar un turno

antesFALLAaffected=1; state after = cancelled
despuésPASAERROR: permission denied for function update_appointment_status

C16 El negocio B no puede confirmar un turno del negocio A

antesFALLAaffected=1; state after = confirmed
despuésPASAERROR: forbidden

C17 El estado del turno solo acepta valores conocidos

antesFALLAaffected=1; state after = hacked
despuésPASAERROR: invalid status

Cómo reproducirlo

-- as anon (no login)
select public.update_appointment_status('<appointment id>', 'cancelled');

Comportamiento esperado

Solo los miembros del negocio del turno pueden cambiarlo, y solo a un estado conocido.

Corrección (extracto del diff)

+  if p_status is null or p_status not in ('pending','confirmed','cancelled','done','no_show') then
+    raise exception 'invalid status' using errcode = '22023';
+  end if;
+  select a.business_id into v_business from public.appointments a where a.id = p_appointment_id;
+  if v_business is null or not public.is_business_member(v_business) then
+    raise exception 'forbidden' using errcode = '42501';
+  end if;
+revoke execute on function public.update_appointment_status(uuid, text) from public, anon;

Controles de este hallazgo

ControlAntesDespués
C15 Un visitante sin login no puede cancelar un turno FALLA PASA
C16 El negocio B no puede confirmar un turno del negocio A FALLA PASA
C17 El estado del turno solo acepta valores conocidos FALLA PASA
C18 El negocio A puede cancelar su propio turno uso normal PASA PASA
F-08 Alta

Los documentos de identidad de clientes se pueden leer y subir entre negocios

Corregido

Impacto. Cualquier usuario logueado puede listar y descargar DNI, estudios médicos y consentimientos de los clientes de todos los negocios, y meter archivos en carpetas de otros negocios.

Ubicación
supabase/migrations/0004_storage.sql · policies on storage.objects (bucket client-docs)
Estándar
V5 Manejo de archivos · V8 Autorización

Qué está mal

Las políticas de storage solo chequean bucket_id = 'client-docs', no la carpeta del negocio en la ruta (<business_id>/<client_id>/archivo). El bucket no tiene límite de tamaño y acepta cualquier tipo de archivo.

Evidencia (salida real de las pruebas)

C19 Un externo no puede listar documentos de identidad de clientes

antesFALLArows=2: (aaaaaaaa-0000-0000-0000-000000000001/ca000000-0000-0000-0000-000000000001/id-card.pdf) | (bbbbbbbb-0000-0000-0000-000000000002/cb000000-0000-0000-0000-000000000002/consent.pdf)
despuésPASArows=0

C21 Un externo no puede subir archivos a la carpeta del negocio A

antesFALLAaffected=1; state after = 1
despuésPASAERROR: new row violates row-level security policy for table "objects"

C28 El bucket client-docs tiene límite de tamaño y lista de tipos (sin HTML/SVG)

antesFALLAsize_limit=none; types=any
despuésPASAsize_limit=10485760; types={application/pdf,image/jpeg,image/png}

Cómo reproducirlo

-- as any logged-in user
select name from storage.objects where bucket_id = 'client-docs';
-- with the listed path, supabase.storage.from('client-docs').createSignedUrl(path, 60) downloads it

Comportamiento esperado

Solo los miembros del negocio de la primera carpeta de la ruta pueden listar, subir o borrar; las subidas se limitan a PDF/JPEG/PNG de hasta 10 MB.

Corrección (extracto del diff)

-create policy "read client docs" on storage.objects
-  for select to authenticated using (bucket_id = 'client-docs');
+create policy "members read client docs" on storage.objects
+  for select to authenticated
+  using (bucket_id = 'client-docs' and public.is_member_of_folder(name));
+update storage.buckets set file_size_limit = 10485760,
+  allowed_mime_types = array['application/pdf','image/jpeg','image/png'] where id = 'client-docs';

Controles de este hallazgo

ControlAntesDespués
C19 Un externo no puede listar documentos de identidad de clientes FALLA PASA
C20 El negocio B no puede listar documentos del negocio A FALLA PASA
C21 Un externo no puede subir archivos a la carpeta del negocio A FALLA PASA
C22 El negocio A puede subir a su propia carpeta uso normal PASA PASA
C23 El negocio A ve solo sus documentos, no los del negocio B FALLA PASA
C28 El bucket client-docs tiene límite de tamaño y lista de tipos (sin HTML/SVG) FALLA PASA
F-09 Alta

El cliente decide el precio, y los códigos de descuento se ven en el sitio

Corregido

Impacto. Una reserva se puede mandar con cualquier precio (por ejemplo 1 centavo). El código AMIGO50 de 50% y los otros cupones los puede leer cualquiera en el código de la página.

Ubicación
supabase/functions/create-booking/index.ts · src/lib/pricing.ts · src/pages/BookingPage.tsx
Estándar
V2 Validación y lógica de negocio

Qué está mal

El navegador calcula el total (con cupones) y create-booking guarda price_cents y business_id tal como llegan. En la app original la seguridad por filas igual bloquea inserts de no miembros, así que hoy el precio lo falsifica una cuenta de staff, y cualquiera combinándolo con F-02.

Evidencia (salida real de las pruebas)

C42 Un cliente que manda price_cents = 1 paga igual el precio real (500000)

antesFALLAHTTP 200; stored row: price_cents=1, business_id=attacker-chosen business
despuésPASAHTTP 200; stored row: price_cents=500000, business_id=service's business

C32 El servidor cotiza el precio real; un cupón inventado no descuenta nada

antesFALLAERROR: function public.quote_price(unknown, unknown) does not exist
despuésPASArows=1: (500000)

C51 No hay códigos de cupón en el código del frontend

antesFALLAsrc/lib/pricing.ts: "BIENVENIDA10", "VERANO25", "AMIGO50"
despuésPASAnone found

Cómo reproducirlo

curl -X POST "https://<project>.supabase.co/functions/v1/create-booking" \
  -H "Authorization: Bearer <user JWT>" -H "Content-Type: application/json" \
  -d '{"service_id":"<id>","business_id":"<other id>","starts_at":"...","ends_at":"...","price_cents":1}'

Comportamiento esperado

Precio, duración y negocio salen de la base; los cupones se validan en el servidor contra una tabla que los usuarios no pueden leer.

Corrección (extracto del diff)

-      business_id: body.business_id,
-      price_cents: body.price_cents,
+    const { data: service } = await supabase.from("services")
+      .select("id, business_id, duration_min").eq("id", body.service_id).eq("active", true).single();
+    const { data: price } = await supabase.rpc("quote_price", { p_service_id: service.id, p_coupon: body.coupon });
+        business_id: service.business_id,
+        price_cents: price,
-- migration 0005: table public.coupons (RLS on, no client access) + function public.quote_price()
También hace falta (manual)

AMIGO50 estuvo público en el bundle viejo: desactivarlo en public.coupons si no debería usarlo cualquiera.

Controles de este hallazgo

ControlAntesDespués
C42 Un cliente que manda price_cents = 1 paga igual el precio real (500000) FALLA PASA
C32 El servidor cotiza el precio real; un cupón inventado no descuenta nada FALLA PASA
C33 Un cupón válido se aplica en el servidor uso normal FALLA PASA
C34 Los usuarios no pueden listar los códigos de cupón FALLA PASA
C51 No hay códigos de cupón en el código del frontend FALLA PASA
F-10 Media

El email y teléfono de todos los usuarios se pueden listar sin login

Corregido

Impacto. Cualquiera en internet puede bajar la lista de usuarios registrados con email, teléfono y quién es admin: material para spam, phishing o ataques dirigidos.

Ubicación
supabase/migrations/0002_rls.sql · policy "profiles are viewable"
Estándar
V8 Autorización · V14 Protección de datos

Qué está mal

Política de SELECT USING (true) sin restricción de rol, así que también aplica a anon.

Evidencia (salida real de las pruebas)

C04 Un visitante sin login no puede listar emails y teléfonos de usuarios

antesFALLArows=4: (owner@clinic-a.test,"+54 11 5555-0a",f) | (owner@salon-b.test,"+54 11 5555-0b",f) | (someone@outside.test,"+54 11 5555-0c",f) | (new@clinic-a.test,"+54 11 5555-0d",f)
despuésPASArows=0

Cómo reproducirlo

curl "https://<project>.supabase.co/rest/v1/profiles?select=email,phone,is_admin" -H "apikey: <public anon key>"

Comportamiento esperado

Cada usuario lee solo su propio perfil.

Corrección (extracto del diff)

-create policy "profiles are viewable" on public.profiles
-  for select using (true);
+create policy "users read own profile" on public.profiles
+  for select to authenticated using (id = auth.uid());

Controles de este hallazgo

ControlAntesDespués
C04 Un visitante sin login no puede listar emails y teléfonos de usuarios FALLA PASA
C05 Un usuario logueado no puede leer perfiles de otros FALLA PASA
C06 El usuario puede leer su propio perfil uso normal PASA PASA
F-11 Media

Cualquiera puede alojar archivos en el bucket público de marketing

Corregido

Impacto. Cualquier cuenta puede subir una página de phishing o malware que queda servida desde la dirección pública de storage de tu proyecto, sin límite de tamaño ni de tipo.

Ubicación
supabase/migrations/0004_storage.sql · bucket marketing · policy "members upload marketing"
Estándar
V5 Manejo de archivos

Qué está mal

Pese al nombre, la política de subida solo chequea bucket_id = 'marketing'. El bucket es público (por diseño, para logos y banners) y no tiene límite de tamaño ni lista de tipos.

Evidencia (salida real de las pruebas)

C24 Un externo no puede alojar archivos (p. ej. phishing) en el bucket público

antesFALLAaffected=1; state after = 1
despuésPASAERROR: new row violates row-level security policy for table "objects"

C29 El bucket marketing tiene límite de tamaño y solo acepta imágenes

antesFALLAsize_limit=none; types=any
despuésPASAsize_limit=2097152; types={image/png,image/jpeg,image/webp}

Cómo reproducirlo

-- as any logged-in user
insert into storage.objects (bucket_id, name, owner) values ('marketing', 'login-verify.html', auth.uid());

Comportamiento esperado

Solo miembros del negocio suben, dentro de su carpeta, solo imágenes, hasta 2 MB. La lectura pública queda como estaba.

Corrección (extracto del diff)

+create policy "members upload marketing" on storage.objects
+  for insert to authenticated
+  with check (bucket_id = 'marketing' and public.is_member_of_folder(name));
+update storage.buckets set file_size_limit = 2097152,
+  allowed_mime_types = array['image/png','image/jpeg','image/webp'] where id = 'marketing';

Controles de este hallazgo

ControlAntesDespués
C24 Un externo no puede alojar archivos (p. ej. phishing) en el bucket público FALLA PASA
C25 El negocio B no puede reemplazar imágenes públicas del negocio A FALLA PASA
C26 El negocio A puede subir su propio banner uso normal PASA PASA
C27 Las imágenes de marketing siguen siendo públicas (por diseño) uso normal PASA PASA
C29 El bucket marketing tiene límite de tamaño y solo acepta imágenes FALLA PASA
F-12 Media

Se pueden hacer reservas superpuestas y en el pasado

Corregido

Impacto. Dos clientes pueden quedarse con el mismo horario, y la página de reservas ofrece horarios que ya pasaron. Molesto para los clientes, costoso para el negocio.

Ubicación
supabase/migrations/0003_functions.sql · get_available_slots · table public.appointments
Estándar
V2 Validación y lógica de negocio

Qué está mal

La disponibilidad solo oculta horarios cuyo inicio es exactamente igual al de una reserva existente, y nada en la base impide superposiciones.

Evidencia (salida real de las pruebas)

C35 Dos reservas no pueden superponerse para el mismo servicio

antesFALLAaffected=1; state after = 2
despuésPASAERROR: conflicting key value violates exclusion constraint "appointments_no_overlap"

C36 La disponibilidad oculta horarios que se superponen con una reserva (10:15-10:45)

antesFALLArows=2: (10:00) | (10:30)
despuésPASArows=0

Cómo reproducirlo

select to_char(slot,'HH24:MI') from public.get_available_slots('<service>', current_date + 7);
-- an existing booking 10:15-10:45 still leaves 10:00 and 10:30 on offer

Comportamiento esperado

La base rechaza reservas superpuestas para el mismo servicio; la disponibilidad excluye superposiciones y horarios pasados.

Corrección (extracto del diff)

+alter table public.appointments add constraint appointments_no_overlap
+  exclude using gist (service_id with =, tstzrange(starts_at, ends_at, '[)') with &&)
+  where (status <> 'cancelled');
+  where g.slot > now()
+    and not exists (... tstzrange(a.starts_at, a.ends_at, '[)') && tstzrange(g.slot, g.slot + duration, '[)'))
También hace falta (manual)

El constraint no se aplica si producción ya tiene reservas activas superpuestas: resolverlas antes.

Controles de este hallazgo

ControlAntesDespués
C35 Dos reservas no pueden superponerse para el mismo servicio FALLA PASA
C36 La disponibilidad oculta horarios que se superponen con una reserva (10:15-10:45) FALLA PASA
C37 La disponibilidad no ofrece horarios en el pasado FALLA PASA
C38 Se siguen ofreciendo horarios futuros libres uso normal PASA PASA
F-13 Media

Las respuestas de error muestran detalles internos

Corregido

Impacto. Un pedido mal formado devuelve stack traces y mensajes crudos de la base que le ayudan a un atacante a mapear el sistema.

Ubicación
payments-webhook/index.ts (catch) · create-booking/index.ts · get-availability/index.ts
Estándar
V16 Registro de seguridad y manejo de errores

Qué está mal

El webhook devuelve {error, stack}; las otras funciones devuelven error.message de la base tal cual.

Evidencia (salida real de las pruebas)

C41 Los errores del webhook no muestran stack traces

antesFALLAHTTP 500; body: {"error":"SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)","stack":"SyntaxError: Expected property name or '}' in JSON at position 1 (line 1 column 2)\n at parse
despuésPASAHTTP 500; body: {"error":"internal_error"}

C43 Los mensajes de error de la base no se devuelven al navegador

antesFALLAHTTP 400; body: {"error":"duplicate key value violates unique constraint \"appointments_internal_idx_7\""}
despuésPASAHTTP 400; body: {"error":"booking_failed"}

Cómo reproducirlo

curl -X POST "https://<project>.supabase.co/functions/v1/payments-webhook" -d '{not json'

Comportamiento esperado

Errores genéricos hacia el cliente; el detalle solo en los logs del servidor.

Corrección (extracto del diff)

-    return new Response(JSON.stringify({ error: String(err), stack: (err as Error).stack }), {
+    console.error("payments-webhook failed", err);  // details stay in server logs
+    return json({ error: "internal_error" }, 500);

Controles de este hallazgo

ControlAntesDespués
C41 Los errores del webhook no muestran stack traces FALLA PASA
C43 Los mensajes de error de la base no se devuelven al navegador FALLA PASA
C52 Las edge functions devuelven errores genéricos (sin stack ni mensajes crudos de la base) FALLA PASA
F-14 Media

Cualquier sitio web puede llamar a las funciones desde el navegador de un visitante

Corregido

Impacto. Las funciones aceptan llamadas cross-origin de cualquier sitio, y una además declara que acepta credenciales, una combinación inválida e insegura.

Ubicación
get-availability/index.ts · create-booking/index.ts · payments-webhook/index.ts
Estándar
V3 Seguridad del frontend web

Qué está mal

Access-Control-Allow-Origin: * en todas las funciones; get-availability agrega Access-Control-Allow-Credentials: true.

Evidencia (salida real de las pruebas)

C44 Un sitio ajeno no recibe acceso cross-origin

antesFALLAOrigin evil.example -> Allow-Origin: *; Allow-Credentials: true
despuésPASAOrigin evil.example -> Allow-Origin: (none); Allow-Credentials: (none)

Cómo reproducirlo

curl -i -X OPTIONS "https://<project>.supabase.co/functions/v1/get-availability" -H "Origin: https://evil.example"

Comportamiento esperado

Solo se permiten los dominios propios de la app (ALLOWED_ORIGINS); nunca credenciales con comodín. El webhook no manda CORS (es servidor a servidor).

Corrección (extracto del diff)

-  "Access-Control-Allow-Origin": "*",
-  "Access-Control-Allow-Credentials": "true",
+// supabase/functions/_shared/cors.ts
+  if (ALLOWED_ORIGINS.includes(origin)) headers["Access-Control-Allow-Origin"] = origin;
También hace falta (manual)

Cargar ALLOWED_ORIGINS con el/los dominio(s) de producción en los secretos de las funciones.

Controles de este hallazgo

ControlAntesDespués
C44 Un sitio ajeno no recibe acceso cross-origin FALLA PASA
C45 El propio dominio de la app puede seguir llamando a la función uso normal PASA PASA
C53 Las edge functions usan una lista de orígenes permitidos (sin '*', nunca con credenciales) FALLA PASA

Alcance y qué no se probó

Cubierto

  • Todas las migraciones SQL (tablas, seguridad por filas, funciones, políticas y buckets de storage), probadas sobre un PostgreSQL 16 real con una capa compatible con Supabase (roles anon / authenticated / service_role, claims del JWT, auth.uid()).
  • Las cuatro edge functions, ejecutadas en Deno con el código real y endpoints de Supabase y Mercado Pago simulados, más chequeo de tipos (deno check).
  • Código del frontend y bundle de producción (vite build, tsc) buscando secretos, precios del lado del cliente y control de acceso.

No cubierto

  • No hubo pentest contra un despliegue en vivo: la app nunca se desplegó; la capa de Supabase es una emulación, no el servicio real.
  • La firma de Mercado Pago se implementó según el esquema documentado por Mercado Pago y se probó con HMAC generados localmente, no con un aviso real.
  • Los límites de tamaño y tipo de archivo los aplica la API de Supabase Storage; acá se verificaron solo como configuración del bucket.
  • Sin pruebas de carga ni de rendimiento, sin revisión de rate limiting, sin pruebas de denegación de servicio.
  • Sin revisión de la configuración de Supabase Auth (confirmación de email, política de contraseñas, MFA), DNS, hosting ni backups.
  • Sin auditoría de dependencias npm / CVE.
  • No es una evaluación de cumplimiento normativo (HIPAA, GDPR, SOC 2, ISO). La demo guarda notas con datos de salud; probamos quién puede leerlas técnicamente, no el cumplimiento regulatorio.

Controles que quedan en tu repo

Las pruebas usadas en este informe quedan en el repositorio, así cada cambio futuro (incluidos los generados por IA) se vuelve a verificar con un solo comando. Los resultados de las dos corridas quedan commiteados en JSON.

supabase/migrations/0005_security_fixes.sql
Todas las correcciones de base en una sola migración revisada.
tests/rls_controls.sql
38 controles de base: crea dos negocios y un externo, ataca con cada rol y deshace los cambios.
tests/edge/edge-controls.ts
9 controles de edge functions: corre las funciones reales en Deno contra Supabase y Mercado Pago simulados.
tests/code-controls.mjs
6 controles de código, incluida una prueba ejecutada del verificador de firma del webhook.
tests/run-controls.sh
Arma una base nueva, corre todo, escribe JSON y termina con error si algo falla (listo para CI).
results/before.json · results/after.json
Las dos corridas citadas en este informe.

Cómo correrlo

$ tests/run-controls.sh . turnos_pro_ci results/latest.json
...
53/53 controls passed  ->  results/latest.json

Los 53 controles

IDControlTipoAntesDespués
C01 Un usuario no puede darse is_admin = true · F-03 Base de datos FALLA PASA
C02 Un usuario no puede cambiar su propio rol a admin · F-03 Base de datos FALLA PASA
C03 El usuario puede seguir editando su nombre y teléfono uso normal · F-03 Base de datos PASA PASA
C04 Un visitante sin login no puede listar emails y teléfonos de usuarios · F-10 Base de datos FALLA PASA
C05 Un usuario logueado no puede leer perfiles de otros · F-10 Base de datos FALLA PASA
C06 El usuario puede leer su propio perfil uso normal · F-10 Base de datos PASA PASA
C07 Un externo no puede agregarse como dueño de otro negocio · F-02 Base de datos FALLA PASA
C08 El dueño puede agregar staff a su propio negocio uso normal · F-02 Base de datos PASA PASA
C09 Un externo no puede leer clientes de ningún negocio · F-06 Base de datos FALLA PASA
C10 El negocio B no puede leer los clientes del negocio A · F-06 Base de datos FALLA PASA
C11 El negocio A puede leer sus propios clientes uso normal · F-06 Base de datos PASA PASA
C12 Un visitante sin login no puede bajar la agenda de un negocio · F-05 Base de datos FALLA PASA
C13 El negocio B no puede bajar la agenda del negocio A · F-05 Base de datos FALLA PASA
C14 El negocio A puede ver su propia agenda uso normal · F-05 Base de datos PASA PASA
C15 Un visitante sin login no puede cancelar un turno · F-07 Base de datos FALLA PASA
C16 El negocio B no puede confirmar un turno del negocio A · F-07 Base de datos FALLA PASA
C17 El estado del turno solo acepta valores conocidos · F-07 Base de datos FALLA PASA
C18 El negocio A puede cancelar su propio turno uso normal · F-07 Base de datos PASA PASA
C19 Un externo no puede listar documentos de identidad de clientes · F-08 Base de datos FALLA PASA
C20 El negocio B no puede listar documentos del negocio A · F-08 Base de datos FALLA PASA
C21 Un externo no puede subir archivos a la carpeta del negocio A · F-08 Base de datos FALLA PASA
C22 El negocio A puede subir a su propia carpeta uso normal · F-08 Base de datos PASA PASA
C23 El negocio A ve solo sus documentos, no los del negocio B · F-08 Base de datos FALLA PASA
C24 Un externo no puede alojar archivos (p. ej. phishing) en el bucket público · F-11 Base de datos FALLA PASA
C25 El negocio B no puede reemplazar imágenes públicas del negocio A · F-11 Base de datos FALLA PASA
C26 El negocio A puede subir su propio banner uso normal · F-11 Base de datos PASA PASA
C27 Las imágenes de marketing siguen siendo públicas (por diseño) uso normal · F-11 Base de datos PASA PASA
C28 El bucket client-docs tiene límite de tamaño y lista de tipos (sin HTML/SVG) · F-08 Base de datos FALLA PASA
C29 El bucket marketing tiene límite de tamaño y solo acepta imágenes · F-11 Base de datos FALLA PASA
C30 Nadie puede registrar un pago "paid" desde el navegador (solo el webhook verificado) · F-04 Base de datos FALLA PASA
C31 Un webhook repetido no puede registrar el mismo pago dos veces · F-04 Base de datos FALLA PASA
C32 El servidor cotiza el precio real; un cupón inventado no descuenta nada · F-09 Base de datos FALLA PASA
C33 Un cupón válido se aplica en el servidor uso normal · F-09 Base de datos FALLA PASA
C34 Los usuarios no pueden listar los códigos de cupón · F-09 Base de datos FALLA PASA
C35 Dos reservas no pueden superponerse para el mismo servicio · F-12 Base de datos FALLA PASA
C36 La disponibilidad oculta horarios que se superponen con una reserva (10:15-10:45) · F-12 Base de datos FALLA PASA
C37 La disponibilidad no ofrece horarios en el pasado · F-12 Base de datos FALLA PASA
C38 Se siguen ofreciendo horarios futuros libres uso normal · F-12 Base de datos PASA PASA
C39 Un aviso falso y sin firma de "pago aprobado" no cambia nada · F-04 Edge function FALLA PASA
C40 Un aviso real firmado (confirmado con Mercado Pago) marca el turno como pagado uso normal · F-04 Edge function FALLA PASA
C41 Los errores del webhook no muestran stack traces · F-13 Edge function FALLA PASA
C42 Un cliente que manda price_cents = 1 paga igual el precio real (500000) · F-09 Edge function FALLA PASA
C43 Los mensajes de error de la base no se devuelven al navegador · F-13 Edge function FALLA PASA
C44 Un sitio ajeno no recibe acceso cross-origin · F-14 Edge function FALLA PASA
C45 El propio dominio de la app puede seguir llamando a la función uso normal · F-14 Edge function PASA PASA
C46 Los datos de admin se niegan a un usuario no admin (chequeo en servidor) · F-01 Edge function FALLA PASA
C47 Un admin real recibe los datos a través del servidor uso normal · F-01 Edge function FALLA PASA
C48 Ninguna clave service_role en el frontend ni en variables VITE_; .env ignorado por git · F-01 Código FALLA PASA
C49 El verificador de firma acepta webhooks reales y rechaza falsos o repetidos (ejecutado) · F-04 Código FALLA PASA
C50 Estado y monto del pago se leen de Mercado Pago y se comparan con el precio del turno · F-04 Código FALLA PASA
C51 No hay códigos de cupón en el código del frontend · F-09 Código FALLA PASA
C52 Las edge functions devuelven errores genéricos (sin stack ni mensajes crudos de la base) · F-13 Código FALLA PASA
C53 Las edge functions usan una lista de orígenes permitidos (sin '*', nunca con credenciales) · F-14 Código FALLA PASA

¿Querés esto para tu app?

La Readiness Review cuesta USD 249 y se acredita completa en la Graduation. Revisá en dos minutos si tu app califica.