Reports
MEDIA[LÓGICA DE NEGOCIO]#3020021

Mozilla Pontoon: cualquier usuario con sesión podía retirar la aprobación de traducciones ajenas por un fallo lógico

Un error en la condición de permisos de /translations/unapprove/ en Pontoon, la plataforma de traducción de Mozilla, dejaba que cualquier usuario con sesión iniciada retirase la aprobación de cualquier traducción ya aprobada.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Pontoon es la herramienta web que usa Mozilla para gestionar las traducciones de sus productos. En ella, cada cadena puede tener una traducción aprobada (marcada con un tic verde), y en teoría solo dos tipos de usuario pueden retirarle la aprobación: quienes tienen permisos de traductor en ese idioma y proyecto, y el propio autor de la traducción.

El investigador adilnbabras encontró que la comprobación de permisos del endpoint POST /translations/unapprove/ tenía un error lógico: dejaba pasar a cualquiera si la traducción ya estaba aprobada. Como solo tiene sentido desaprobar algo que está aprobado, en la práctica cualquier cuenta con sesión iniciada podía desaprobar cualquier traducción. Es una escalada de privilegios vertical: un usuario sin permisos ejecutaba una acción reservada a perfiles con más privilegios.

El reporte se envió el 1 de marzo de 2025, Mozilla lo dio por resuelto, pagó una recompensa (importe no público) y se divulgó el 10 de abril de 2026. Las pruebas se hicieron contra el entorno de staging de Pontoon.

El código culpable

El investigador señaló este fragmento de pontoon/translations/views.py:

   # Only privileged users or authors can un-approve translations
    if not (
        request.user.can_translate(locale, project)
        or request.user == translation.user
        or translation.approved
    ):
        return JsonResponse(
            {
                "status": False,
                "message": "Forbidden: You can't unapprove this translation.",
            },
            status=403,
        )

    translation.unapprove(request.user)

La intención, según el propio comentario, era rechazar a quien no fuese ni traductor con permisos ni autor. Pero el tercer término del or, translation.approved, no tiene nada que ver con quién hace la petición: describe el estado de la traducción. Si la traducción está aprobada, toda la expresión dentro del not (...) vale True, el if no entra y nunca se devuelve el 403. El código llega directamente a translation.unapprove(request.user).

Pasos de reproducción

  1. Iniciar sesión en Pontoon (en el reporte, https://mozilla-pontoon-staging.herokuapp.com/) con una cuenta sin permisos especiales.
  2. Entrar en Teams y elegir cualquier equipo de idioma.
  3. Elegir un proyecto y, dentro de él, un recurso. Aparece la lista de cadenas traducibles.
  4. Seleccionar una cadena que tenga una traducción ya aprobada (con el tic verde). Desde la interfaz no hay opción de desaprobarla, porque la cuenta no tiene privilegios.
  5. Configurar un proxy de interceptación (tipo Burp) y recargar la página.
  6. En el historial del proxy, localizar la petición al endpoint /get-history/. En su respuesta aparece el ID de la traducción aprobada; copiarlo.
  7. Enviar una petición a /translations/unapprove/ con las cookies de sesión y el token anti-CSRF propios y el ID copiado en el parámetro translation:
POST /translations/unapprove/ HTTP/1.1
Host: mozilla-pontoon-staging.herokuapp.com
Cookie: [REDACTADO]
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Prefer: safe
Referer: https://mozilla-pontoon-staging.herokuapp.com/nl/amo-frontend/LC_MESSAGES/amo.po/?string=175106
X-Csrftoken: [REDACTADO]
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Content-Length: 52
Origin: https://mozilla-pontoon-staging.herokuapp.com
Dnt: 1
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers
Connection: keep-alive

translation=5184479&paths%5B%5D=LC_MESSAGES%2Famo.po
  1. El servidor responde 200 OK.
  2. Al recargar la página, la traducción ya no figura como aprobada: la ha desaprobado un usuario sin privilegios.

Impacto

Cualquier persona con una cuenta en Pontoon podía retirar la aprobación de cualquier traducción aprobada sin ser revisora ni autora. Bastaba con conocer el ID de la traducción, que la propia aplicación expone en la respuesta de /get-history/. El reporte lo resume así: el usuario podía realizar acciones para las que no estaba autorizado.

Qué aprender de este caso

  • En las comprobaciones de permisos escritas como if not (A or B or C), revisa que cada término hable de quién hace la petición. Una condición sobre el estado del objeto (aquí translation.approved) metida en el mismo or abre la puerta a todo el que cumpla ese estado.
  • Cuando la interfaz oculta un botón (aquí, "desaprobar") a un usuario sin permisos, prueba a enviar a mano la petición del endpoint que ese botón llamaría. Los IDs necesarios suelen aparecer en otras respuestas de la API, como pasaba con /get-history/.
  • Para evitarlo, separa la lógica en dos pasos: primero "¿tiene sentido la acción?" (¿está aprobada?) y después "¿puede este usuario hacerla?" (¿traductor o autor?), y cubre con un test el caso de un usuario sin permisos sobre una traducción aprobada, que debe recibir un 403.