HexBrain

Mozilla · Report #3734676

Taskcluster web-server OAuth2 authorization codes are reusable and the exchange handler checks the wrong expiry column

Validstatus
mediumseverity
$2,000bounty
43upvotes
23 Jun 2026disclosed

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:

  1. The exchange handler validates redirect_uri and code existence, mints a bridge access token, and returns — it never deletes or otherwise consumes the authorization_codes row. 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..."

  2. 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's maxExpires, e.g. "1 year" in the test config). The authorization-code row's intended 10-minute lifetime (entry.expires, set at line 33 to taskcluster.fromNow('10 minutes')) is never checked at exchange time. Expired codes remain usable until the daily cleanup-expire-auth-codes cron 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:

  1. Authorizes a code through GET /login/oauth/authorize using the test-code-whitelisted registered client that ships in services/web-server/config.yml.
  2. Exchanges the code through POST /login/oauth/token (asserts 200, token A).
  3. Replays the same code through POST /login/oauth/token (asserts 200, token B, token A != token B — the RFC 6749 §4.1.2 violation).
  4. Calls GET /login/oauth/credentials with Authorization: Bearer <token B> (asserts 200, Taskcluster credentials with clientId + accessToken in 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-advisories returns [].
  • 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:

  1. Replay POST /login/oauth/token to mint a fresh OAuth bridge access token bound to the original user's identity, identity_provider_id, and consented scope set.
  2. Call GET /login/oauth/credentials with that bridge token to receive Taskcluster credentials in the original user's identity. The auth service's create_client behavior (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.
  3. Replay arbitrarily often within the practical window bounded by (a) client_details.expires and (b) the daily cleanup cron. With the documented default of maxExpires up to 1 year and authorizationCodeExpirationDelay: '- 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.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original