GHSA-86j7-9j95-vpqj Better Auth has stored XSS in the auth-server origin via javascript: redirect_uri in oidc-provider and mcp
Am I affected?Check each condition. Users are affected when all of the first three hold.Their application enables the `oidc-provider` plugin or the `mcp` plugin from `better-auth/plugins`. The `mcp` plugin wraps the same provider and carries the same defect. Both are on the migration path to `@better-auth/oauth-provider`, which is not affected.Their application `better-auth` version is `1.6.12` or earlier on the stable line, or any `1.7.0-beta` build on the pre-release line. Both release lines carry the same defect.Their application consent page is a client component that reads `redirectURI` from the `authClient.oauth2.consent(...)` response and assigns it to a browser navigation target such as `window.location.href`, `location.assign`, or `location.replace`.Two conditions raise an application's exposure:It sets `allowDynamicClientRegistration: true`. Client registration is then unauthenticated, so any visitor can plant the malicious client. The default is `false`, which limits planting to authenticated users.It exposes the consent page to ordinary end users rather than to a restricted operator audience.A developer's application is not affected when any of these hold:Their application copied the project's `consent-buttons.tsx` demo verbatim. That file reads a `uri` field that this plugin never returns, so its navigation branch never runs and it falls through to an error toast.Their application completse the consent flow with a server-side redirect, for example a Next.js or Remix server action. Browsers do not follow a `javascript:` URL delivered in a `Location` response header.Their application uses `@better-auth/oauth-provider` instead of the deprecated `oidc-provider` plugin.Fix:Upgrade to `better-auth@1.6.13` (stable) or `1.7.0-beta.4` (pre-release).If developers cannot upgrade their applications, see the workarounds below.SummaryThe deprecated `oidc-provider` plugin registers OAuth clients without validating the scheme of their `redirect_uris`. An attacker stores a `javascript:` URI as a client redirect target, and the authorization server later returns that URI to the browser in the consent response. A consent page that navigates to the returned value then executes attacker JavaScript in the authorization-server origin, which exposes the victim's session and enables account takeover. The `mcp` plugin wraps the same provider and carries the same defect, so MCP server deployments are affected as well; like `oidc-provider`, it is migrating to `@better-auth/oauth-provider`.DetailsClient registration accepts any string. The registration body schema types `redirect_uris` as `z.array(z.string())` with no scheme check, so `POST /oauth2/register` stores a value such as `javascript:fetch('/api/auth/get-session')//`. In the default configuration, `allowDynamicClientRegistration` is `false`, so registration requires an authenticated session; any logged-in user qualifies. With dynamic client registration enabled, registration is unauthenticated.The dangerous value then survives the authorization-code flow unchanged. `GET /oauth2/authorize` matches the request `redirect_uri` against the stored list by exact string equality, so the registered `javascript:` URI matches itself and is written into the consent verification record. When the user approves on the consent screen, the handler runs `new URL(value.redirectURI)`, appends the authorization code through `searchParams.set`, and returns the result in JSON under the key `redirectURI`. The `javascript:` scheme passes through `new URL` intact, and a trailing `//` in the payload comments out the appended query string.The plugin documents the consent call but not the redirect step that follows it, and it gives no warning that `redirectURI` can carry a dangerous scheme. The natural way an operator completes the flow is to assign `res.data.redirectURI` to `window.location.href`. Per the URL specification and browser behavior, navigating `window.location` to a `javascript:` URL executes the script body in the current document origin, which here is the authorization server. The injected script can call `/api/auth/get-session` and any other session-scoped endpoint in that origin.The sibling `@better-auth/oauth-provider` package already prevents this. It validates the same field with `SafeUrlSchema`, which rejects the `javascript:`, `data:`, and `vbscript:` schemes and requires HTTPS or a loopback host. The deprecated plugin never received that control; the field shipped unvalidated when the registration endpoint was first added, well before the sibling package introduced `SafeUrlSchema`.PatchesFixed in `better-auth@1.6.13` (stable) and `1.7.0-beta.4` (pre-release). The fix adds scheme validation to the `redirect_uris` of both the `oidc-provider` and `mcp` plugins, matching the control `@better-auth/oauth-provider` already enforces.WorkaroundsIf developers cannot upgrade, apply one of the following.Harden the consent page. Before navigating, parse the returned value and navigate only when its scheme is `http:` or `https:`. For example, reject the value when `new URL(redirectURI).protocol` is neither `http:` nor `https:`, and show an error instead of navigating. This closes the sink regardless of what the server returns.Migrate to `@better-auth/oauth-provider`. It validates redirect URIs at registration and is the supported replacement for the deprecated plugin.Keep `allowDynamicClientRegistration` at its default of `false` and restrict who can register clients. This does not remove the issue, because any authenticated user can still register a malicious client, but it removes the unauthenticated path.ImpactThis is a stored DOM cross-site scripting issue in the authorization server's own origin, which leads to account takeover. An attacker who can register a client plants a `javascript:` redirect URI. A victim who visits the attacker's authorize URL and approves consent runs attacker JavaScript in the authorization-server origin. From there the script reads `/api/auth/get-session`, calls any session-scoped endpoint, and takes over the account. The consent screen shows the attacker-controlled client name, which makes the approval click easy to socially engineer.The realized impact depends on one operator-side precondition: the consent page must assign the returned `redirectURI` to a navigation target. Better Auth itself never performs that navigation, which is why the assessed Attack Complexity is High and the score sits below a pure server-side execution flaw. The deprecated status of the `oidc-provider` plugin does not reduce the impact. The plugin remains published, importable, and documented, and users on the affected versions cannot opt out without changing their integration. The `mcp` plugin wraps the same provider and is affected on the same versions; its docs announce a move to `@better-auth/oauth-provider`, but until users migrate or upgrade, the affected published versions stay exposed. `@better-auth/oauth-provider` validates redirect URIs and provides MCP support, so it is the migration target for both plugins.CreditReported by @hillaleeResourcesCWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'). https://cwe.mitre.org/data/definitions/79.htmlCWE-601: URL Redirection to Untrusted Site ('Open Redirect'). https://cwe.mitre.org/data/definitions/601.htmlRFC 6749 (OAuth 2.0) Section 3.1.2, Redirection Endpoint: the redirection endpoint URI must be an absolute URI. https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2MDN, javascript: URLs: navigating `window.location` to a `javascript:` URL executes the script body. https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript
CVSS v3.1 score: 7.7