Divulgación · Control de acceso en plataforma de cursos

Cursos pagos al alcance de cualquiera

Read in English

El cliente era una plataforma con un modelo de negocio simple: se paga la suscripción y se accede a los cursos. En una revisión encontré que el "muro de pago" existía en la interfaz, pero no en el servidor. Esta es la clase de bug que se repite en casi toda plataforma de contenido: se protege la página, no el dato.

1. Un subdominio con el contenido completo sin pagar

Había un subdominio legacy corriendo un LMS (TutorLMS/WordPress) con los cursos alojados. Cualquier persona podía ver las clases completas de ambos cursos sin pagar ni crear cuenta. Peor: el listado de todas las lecciones quedaba publicado por el propio WordPress en un archivo de sitemap (wp-sitemap-posts-lesson-1.xml), así que ni siquiera había que adivinar las URLs.

Si el subdominio no se usa (no se actualizaba desde hacía un año y solo tenía dos cursos), lo más limpio es eliminarlo. Si se mantiene, hay que forzar la verificación de inscripción antes de mostrar cada clase.

2. La API que devolvía todo el contenido sin sesión

En la plataforma principal, el endpoint que arma la ficha de un curso devolvía todo el contenido —URLs de video y PDFs— sin pedir login, incluso para cursos marcados como "solo suscriptores":

curl "https://<plataforma>/api/cursos/course-by-id/<id-del-curso>"
# -> 200 OK
#    { "title": "...", "isSubscribersOnly": true,
#      "lessons": [ { "videoUrl": "...", "pdfUrl": "..." }, ... ] }
#    (sin cookies, sin token, sin suscripción)

La corrección es exigir sesión y verificar la inscripción antes de serializar videoUrl/pdfUrl; sin acceso, devolver solo la información pública (título, imagen, precio). Lo ideal es no enviar nunca la URL del asset: resolver el video server-side tras validar el permiso.

3. Endpoints que aceptan IDs ajenos sin login

Los endpoints de progreso del curso aceptaban cualquier ID de curso y de usuario sin pedir autenticación, y las consultas llegaban hasta la base de datos real (el comportamiento cambiaba según si el usuario existía o no):

# Sin login, con IDs que controla el atacante:
GET /api/cursos/course-progress?courseId=<...>&user=<...>
GET /api/cursos/course-progress/suscriptions?courseId=<...>&user=<...>

4. La protección que estaba solo en la interfaz

En la Tabla de Líderes, los nombres aparecían difuminados por CSS, dando la impresión de estar protegidos. Pero el nombre completo seguía en el HTML, y el endpoint de la tabla devolvía el Top 100 con nombres sin anonimizar:

curl "https://<plataforma>/api/leaderboard?limit=100"
# -> 200 con los nombres completos de los 100 primeros

Difuminar en el cliente es decoración. Si el dato no debe verse, no debe enviarse.

5. El precio del checkout lo definía la URL

En el checkout de suscripciones, el importe salía de un parámetro de la URL. Abrir el plan anual con ?type=premium lo dejaba en una fracción del precio; con ?type=plus, en otra fracción del basic.

GET /api/mercadopago/bricks-subscription/quote?suscriptionPlanId=<plan-anual>&tier=premium
# -> un importe muy inferior al precio real del plan

Lo probé con mi propia cuenta: el procesador cobró el monto manipulado. El precio de una transacción nunca debe salir de un dato que manda el cliente: se deriva server-side del plan.

6. Listas de correos con acceso, en el JavaScript público

Quedaron hardcodeadas en el bundle público dos listas: la de acceso gratuito a un producto nuevo y la de administradores. La primera otorgaba acceso sin suscripción a quien controlara uno de esos buzones; la segunda, el panel completo.

  • Producto nuevo: con solo registrar y verificar un correo de la lista (aunque no existiera antes), el servidor emitía un SSO y daba acceso gratis al producto de pago.
  • Panel admin: la lista de correos de administradores también estaba en el bundle, con direcciones personales (gmail/hotmail/live), sin SSO ni MFA corporativo. Quien controle o registre uno de esos buzones se lleva el panel.
  • Y por si faltara: el endpoint que reenvía eventos a la API de conversiones de Meta aceptaba eventos sin login ni firma, con montos y datos de terceros incluidos, y Meta los aceptaba. Un competidor puede envenenar la medición y la pauta.

Además: los errores contaban de más

Con un ID inválido en course-progress, suscriptions o course-by-id, el servidor devolvía el error técnico completo, con el motor de base de datos y detalles de las tablas internas. No es grave por sí solo, pero es un mapa gratis para quien quiera buscar más fallas: los errores deben ser genéricos.

La lección

El contenido se protege en el servidor, no en el cliente. Sesión para saber quién sos, verificación de permiso para saber qué podés ver, y el precio siempre calculado del lado del servidor.
← Volver a Seguridad