Reports
MEDIA[OAUTH Y SESIONES]#3734676

Taskcluster (Mozilla): los códigos de autorización OAuth2 se podían reutilizar y su caducidad no se comprobaba

El servidor web de Taskcluster no invalidaba los códigos de autorización OAuth2 tras canjearlos y miraba la columna de caducidad equivocada, así que un código filtrado servía para obtener credenciales del usuario una y otra vez.

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

Taskcluster, el sistema de integración continua que Mozilla usa, entre otras cosas, para Firefox, tiene un servidor web que actúa como servidor de autorización OAuth2. El fallo estaba en el manejador que canjea el código de autorización por un token, en services/web-server/src/servers/oauth2.js (líneas 165-199), y en realidad eran dos errores juntos:

  1. El código no se consumía. El manejador comprobaba que el redirect_uri coincidía y que el código existía, generaba un token de acceso "puente" (bridge access token) y respondía. Pero nunca borraba ni marcaba como usada la fila correspondiente de la tabla authorization_codes. El RFC 6749 (§4.1.2) exige que un código de autorización sea de un solo uso y que el servidor rechace los intentos de reutilizarlo.
  1. Se comprobaba la caducidad equivocada. La comprobación de la línea 66 leía entry.client_details.expires, que no es la vida del código, sino la duración solicitada para las credenciales de Taskcluster que se van a emitir (limitada por el maxExpires del cliente OAuth registrado, que en la configuración de pruebas es "1 year"). La caducidad real del código, entry.expires, fijada en la línea 33 con taskcluster.fromNow('10 minutes'), no se miraba nunca en el canje. Un código caducado seguía funcionando hasta que la tarea diaria cleanup-expire-auth-codes lo borraba (services/web-server/procs.yml:20-24).

El mismo error con la columna de caducidad aparecía en el endpoint que convierte el token puente en credenciales, /login/oauth/credentials (oauth2.js:318-375), que también comprobaba entry.client_details.expires en lugar del entry.expires de la fila del token.

HackerOne lo clasifica como Authentication Bypass by Capture-replay. Mozilla lo dio por resuelto y pagó recompensa, aunque no consta el importe. Se envió el 14 de mayo de 2026 y se hizo público el 23 de junio de 2026.

Pasos de reproducción

El investigador lo demostró con una prueba de extremo a extremo contra el código original de Taskcluster (commit 246cb765672da1fdc1b7e6901002725bf0e50090, rama main), añadiendo un caso a services/web-server/test/third_party_test.js y ejecutándolo con la infraestructura Mocha del propio proyecto.

  1. Preparar el entorno y lanzar la prueba:
$ source ~/.nvm/nvm.sh && nvm use 24.15.0
$ cd taskcluster && yarn install
$ docker compose up -d postgres
$ docker exec taskcluster-postgres-1 psql -U postgres -c "CREATE DATABASE \"taskcluster-test\";"
$ cd services/web-server
$ TEST_DB_URL=postgresql://postgres@localhost:5432/taskcluster-test NODE_ENV=test \
    yarn mocha --grep "TC-002" test/third_party_test.js
  1. La prueba obtiene un código de autorización con GET /login/oauth/authorize, usando el cliente registrado test-code-whitelisted que viene en services/web-server/config.yml.
  2. Canjea ese código con POST /login/oauth/token: respuesta 200 y un primer token (A).
  3. Vuelve a enviar el mismo código a POST /login/oauth/token: otra vez 200 y un segundo token (B), distinto de A. Aquí está la violación del RFC: el segundo canje debería haberse rechazado.
  4. Llama a GET /login/oauth/credentials con la cabecera Authorization: Bearer <token B>: respuesta 200 con credenciales de Taskcluster (clientId y accessToken) a nombre del usuario original.

Resultado de la ejecución:

services/web-server/test/third_party_test.js
  unit
    ✔ TC-002 (PoC): authorization code is reusable in violation of RFC 6749 §4.1.2

1 passing

Impacto

Quien consiguiera capturar un código de autorización (por ejemplo, a través de la cabecera Referer en ciertas condiciones, de los registros de la página de redirección, de un proxy que inspeccione TLS o del historial del navegador en esa página) podía:

  • Canjearlo de nuevo en POST /login/oauth/token para obtener un token puente ligado a la identidad del usuario (identity, identity_provider_id) y a los permisos (scopes) que este había autorizado.
  • Usar ese token en GET /login/oauth/credentials para recibir credenciales de Taskcluster a nombre de la víctima. Según el reporte, el servicio de autenticación (create_client, en db/versions/0041.yml:343-404) devuelve el token del cliente ya existente si se le llama dentro de una ventana de 15 minutos; fuera de ella, el token puente sigue pudiendo generar credenciales si el atacante es el primero en convertirlo.
  • Repetirlo tantas veces como quisiera mientras durase la ventana real, marcada por client_details.expires y por la limpieza diaria. Con un maxExpires de hasta un año y authorizationCodeExpirationDelay: '- 10 minutes', esa ventana se alargaba muchas horas más allá de los 10 minutos previstos.

En firefox-ci-tc.services.mozilla.com, esas credenciales llevan los permisos de Firefox-CI del usuario. Si el código lo había autorizado una identidad con roles privilegiados (por ejemplo, mozilla-employee), el atacante podía obtener credenciales con esos permisos en la CI de Firefox, lo que según el investigador compromete la integridad de ese entorno de compilación.

Remediación

El investigador propuso consumir el código de forma atómica en el canje, con una función de base de datos del estilo de consume_authorization_code(code, client_id, redirect_uri) que haga en una sola sentencia algo equivalente a:

DELETE FROM authorization_codes
 WHERE code = code_in
   AND client_id = client_id_in
   AND redirect_uri = redirect_uri_in
   AND expires > now()
 RETURNING code, client_id, redirect_uri, identity, identity_provider_id, expires, client_details;

La fila devuelta sería la única condición para emitir un token de acceso: si no vuelve nada (código ya usado, caducado o con datos que no coinciden), no hay token. En /login/oauth/credentials, la corrección equivalente es comprobar el entry.expires de la fila del token en lugar de entry.client_details.expires.

Como medida adicional, sugirió guardar en cada token de acceso una referencia al código del que salió, para poder revocar los tokens ya emitidos si se detecta una reutilización, tal como recomienda el RFC 6749 en el mismo apartado. El reporte figura como resuelto, pero no detalla qué cambio aplicó finalmente Mozilla.