Mozilla · Report #3734676
Taskcluster web-server OAuth2 authorization codes are reusable and the exchange handler checks the wrong expiry column
How it was found
The Taskcluster web-server's OAuth2 token-exchange handler does not consume authorization codes and does not enforce the authorization-code expiry. A leaked authorization code can be replayed to mint additional bridge access tokens for the original user, well past the 10-minute window that RFC 6749 §4.1.2 requires.
Source-to-sink
Two bugs in services/web-server/src/servers/oauth2.js, both in the same handler at lines 165-199:
-
The exchange handler validates
redirect_uriand code existence, mints a bridge access token, and returns — it never deletes or otherwise consumes theauthorization_codesrow. RFC 6749 §4.1.2 requires single-use codes: "The client MUST NOT use the authorization code more than once. If an authorization code is used more than once, the authorization server MUST deny the request..." -
The expiry check at line 66 reads
entry.client_details.expires, which is the requested lifetime of the resulting Taskcluster credentials (capped by the registered OAuth client'smaxExpires, e.g."1 year"in the test config). The authorization-code row's intended 10-minute lifetime (entry.expires, set at line 33 totaskcluster.fromNow('10 minutes')) is never checked at exchange time. Expired codes remain usable until the dailycleanup-expire-auth-codescron deletes them (services/web-server/procs.yml:20-24).
The same expiry-column mismatch repeats in the bridge-token-to-credentials handler at oauth2.js:318-375 (/login/oauth/credentials), which checks entry.client_details.expires rather than the access-token row's entry.expires.
Reproduction (end-to-end PoC)
End-to-end PoC reproduces against unmodified Taskcluster source at commit 246cb765672da1fdc1b7e6901002725bf0e50090 (default branch main). The test case extends the existing services/web-server/test/third_party_test.js and runs via the project's existing Mocha test infrastructure:
$ 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
Result:
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
The test:
- Authorizes a code through
GET /login/oauth/authorizeusing thetest-code-whitelistedregistered client that ships inservices/web-server/config.yml. - Exchanges the code through
POST /login/oauth/token(asserts 200, token A). - Replays the same code through
POST /login/oauth/token(asserts 200, token B,token A != token B— the RFC 6749 §4.1.2 violation). - Calls
GET /login/oauth/credentialswithAuthorization: Bearer <token B>(asserts 200, Taskcluster credentials withclientId+accessTokenin the original user's identity).
The full test patch and Mocha pass log are attached.
Remediation
Consume the authorization code atomically during token exchange. Add a DB function such as consume_authorization_code(code, client_id, redirect_uri) that performs a single statement equivalent to:
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;
Use the returned row as the only gate for creating an access token. Apply the same fix to /login/oauth/credentials: check the access-token row's top-level entry.expires rather than entry.client_details.expires.
Optionally, store a back-reference from access tokens to the originating authorization code and revoke or invalidate already-minted tokens if replay is detected, matching RFC 6749's "SHOULD revoke" clause in §4.1.2.
Duplicate / advisory checks (performed 2026-05-14)
- GitHub Security Advisories on
taskcluster/taskcluster:gh api repos/taskcluster/taskcluster/security-advisoriesreturns[]. - Mozilla Foundation Security Advisories index: no matching entry for taskcluster or OAuth code reuse.
- HackerOne disclosed reports mentioning "taskcluster" globally: 3 (#2915647 Netlify token in CI logs, #2221404 RCE via task env variable, #2696294 private emails leaked). None cover OAuth2 authorization-code reuse or expiry-mismatch.
Impact
With a captured authorization code (referer in narrow conditions, redirect-page logs, TLS-inspecting proxy, browser-history access on the redirect page), the attacker can:
- Replay
POST /login/oauth/tokento mint a fresh OAuth bridge access token bound to the original user'sidentity,identity_provider_id, and consented scope set. - Call
GET /login/oauth/credentialswith that bridge token to receive Taskcluster credentials in the original user's identity. The auth service'screate_clientbehavior (db/versions/0041.yml:343-404) returns the existing client's access token when called within a 15-minute idempotency window; outside that window the bridge token can still mint credentials if the attacker is the first to convert the bridge token to credentials. - Replay arbitrarily often within the practical window bounded by (a)
client_details.expiresand (b) the daily cleanup cron. With the documented default ofmaxExpiresup to 1 year andauthorizationCodeExpirationDelay: '- 10 minutes', the window extends to many hours past the intended 10-minute code lifetime.
For firefox-ci-tc.services.mozilla.com, the resulting Taskcluster credentials carry the user's Firefox-CI scopes, which directly impacts the Firefox build CI integrity boundary. A Taskcluster identity that holds Firefox-CI scopes (e.g. mozilla-employee roles) is a privileged role; replay of a code authorized by such an identity is a meaningful integrity exposure on a Critical Site asset.
The local PoC demonstrates the full chain — authorize → first exchange → replay exchange → credentials conversion — succeeds end-to-end with no error.