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:
- El código no se consumía. El manejador comprobaba que el
redirect_uricoincidí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 tablaauthorization_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.
- 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 elmaxExpiresdel 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 contaskcluster.fromNow('10 minutes'), no se miraba nunca en el canje. Un código caducado seguía funcionando hasta que la tarea diariacleanup-expire-auth-codeslo 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.
- 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
- La prueba obtiene un código de autorización con
GET /login/oauth/authorize, usando el cliente registradotest-code-whitelistedque viene enservices/web-server/config.yml. - Canjea ese código con
POST /login/oauth/token: respuesta 200 y un primer token (A). - 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. - Llama a
GET /login/oauth/credentialscon la cabeceraAuthorization: Bearer <token B>: respuesta 200 con credenciales de Taskcluster (clientIdyaccessToken) 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/tokenpara 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/credentialspara recibir credenciales de Taskcluster a nombre de la víctima. Según el reporte, el servicio de autenticación (create_client, endb/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.expiresy por la limpieza diaria. Con unmaxExpiresde hasta un año yauthorizationCodeExpirationDelay: '- 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.