GHSA-86j7-9j95-vpqj
Better Auth has stored XSS in the auth-server origin via javascript: redirect_uri in oidc-provider and mcp
Details
### 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:
1. Upgrade to `better-auth@1.6.13` (stable) or `1.7.0-beta.4` (pre-release). 2. If developers cannot upgrade their applications, see the workarounds below.
### Summary
The 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`.
### Details
Client 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`.
### Patches
Fixed 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.
### Workarounds
If 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.
### Impact
This 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.
### Credit
Reported by @hillalee
### Resources
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'). https://cwe.mitre.org/data/definitions/79.html - CWE-601: URL Redirection to Untrusted Site ('Open Redirect'). https://cwe.mitre.org/data/definitions/601.html - RFC 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.2 - MDN, 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
Are you affected?
Enter the version of the package you're using.
Affected packages
1.7.0-beta.0 Fixed in: 1.7.0-beta.4 npm install better-auth@1.7.0-beta.4