Ronda 1 + verificación de correcciones
Informe de preparación para producción
Vista para el fundador
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).
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
- Rotar la clave service_role en Supabase y borrar el .env del historial de git (F-01).
- Aplicar la migración 0005_security_fixes.sql y desplegar las cuatro edge functions actualizadas.
- Cargar los secretos de las funciones: MP_WEBHOOK_SECRET, MP_ACCESS_TOKEN, ALLOWED_ORIGINS.
- Revisar datos de producción: filas inesperadas en staff, cuentas con is_admin = true, reservas activas superpuestas.
Próximos 7 días
- Hacer un pago de punta a punta en el sandbox de Mercado Pago y confirmar que el turno pasa a confirmado.
- Sumar tests/run-controls.sh al CI para que cada cambio de Lovable se vuelva a probar antes de desplegar.
- Desactivar el cupón AMIGO50 si no debe seguir siendo público.
- 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
| ID | Gravedad | Qué significa para vos | Estado |
|---|---|---|---|
| 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
service_role referenced in: src/integrations/supabase/admin.ts, .env; .env not in .gitignoreno references; .env ignoredC46 Los datos de admin se niegan a un usuario no admin (chequeo en servidor)
no server-side admin endpoint: the admin panel queries the database from the browser with the service_role keyHTTP 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);
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
| Control | Antes | Despué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
affected=1; state after = 1ERROR: 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));
Revisar la tabla staff en producción y borrar membresías que nadie aprobó.
Controles de este hallazgo
| Control | Antes | Despué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
affected=1; state after = trueERROR: permission denied for table profilesC02 Un usuario no puede cambiar su propio rol a admin
affected=1; state after = adminERROR: permission denied for table profilesCó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;
Revisar qué cuentas tienen hoy is_admin = true o role = 'admin' en producción.
Controles de este hallazgo
| Control | Antes | Despué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
HTTP 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"}HTTP 401; database writes: noneC40 Un aviso real firmado (confirmado con Mercado Pago) marca el turno como pagadouso normal
HTTP 400 {"error":"missing appointment"}; database writes: noneHTTP 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)
affected=1; state after = 1ERROR: new row violates row-level security policy for table "payments"C31 Un webhook repetido no puede registrar el mismo pago dos veces
affected=2; state after = 2ERROR: 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);
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
| Control | Antes | Despué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
rows=1: ("Patient A","+54 11 4444-0001",500000)ERROR: permission denied for function get_business_appointmentsC13 El negocio B no puede bajar la agenda del negocio A
rows=1: ("Patient A","+54 11 4444-0001",500000)ERROR: forbiddenCó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
| Control | Antes | Despué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
rows=2: ("Patient A",[email protected],"+54 11 4444-0001","allergic to penicillin") | ("Customer B",[email protected],"+54 11 4444-0002","prefers mornings")rows=0C10 El negocio B no puede leer los clientes del negocio A
rows=1: ("Patient A","+54 11 4444-0001","allergic to penicillin")rows=0Có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
| Control | Antes | Despué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
affected=1; state after = cancelledERROR: permission denied for function update_appointment_statusC16 El negocio B no puede confirmar un turno del negocio A
affected=1; state after = confirmedERROR: forbiddenC17 El estado del turno solo acepta valores conocidos
affected=1; state after = hackedERROR: invalid statusCó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
| Control | Antes | Despué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
rows=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)rows=0C21 Un externo no puede subir archivos a la carpeta del negocio A
affected=1; state after = 1ERROR: 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)
size_limit=none; types=anysize_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
| Control | Antes | Despué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)
HTTP 200; stored row: price_cents=1, business_id=attacker-chosen businessHTTP 200; stored row: price_cents=500000, business_id=service's businessC32 El servidor cotiza el precio real; un cupón inventado no descuenta nada
ERROR: function public.quote_price(unknown, unknown) does not existrows=1: (500000)C51 No hay códigos de cupón en el código del frontend
src/lib/pricing.ts: "BIENVENIDA10", "VERANO25", "AMIGO50"none foundCó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()
AMIGO50 estuvo público en el bundle viejo: desactivarlo en public.coupons si no debería usarlo cualquiera.
Controles de este hallazgo
| Control | Antes | Despué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
rows=4: ([email protected],"+54 11 5555-0a",f) | ([email protected],"+54 11 5555-0b",f) | ([email protected],"+54 11 5555-0c",f) | ([email protected],"+54 11 5555-0d",f)rows=0Có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
| Control | Antes | Despué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
affected=1; state after = 1ERROR: 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
size_limit=none; types=anysize_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
| Control | Antes | Despué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
affected=1; state after = 2ERROR: conflicting key value violates exclusion constraint "appointments_no_overlap"C36 La disponibilidad oculta horarios que se superponen con una reserva (10:15-10:45)
rows=2: (10:00) | (10:30)rows=0Có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, '[)'))
El constraint no se aplica si producción ya tiene reservas activas superpuestas: resolverlas antes.
Controles de este hallazgo
| Control | Antes | Despué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
HTTP 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 HTTP 500; body: {"error":"internal_error"}C43 Los mensajes de error de la base no se devuelven al navegador
HTTP 400; body: {"error":"duplicate key value violates unique constraint \"appointments_internal_idx_7\""}HTTP 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
| Control | Antes | Despué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
Origin evil.example -> Allow-Origin: *; Allow-Credentials: trueOrigin 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;
Cargar ALLOWED_ORIGINS con el/los dominio(s) de producción en los secretos de las funciones.
Controles de este hallazgo
| Control | Antes | Despué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
| ID | Control | Tipo | Antes | Despué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.