# Root Cause Analysis: GHSA-5rr4-8452-hf4v (@better-auth/sso SSRF → account takeover)

## Summary

`@better-auth/sso` versions `< 1.6.11` allow an authenticated attacker to register or update an OIDC SSO provider with arbitrary attacker-controlled endpoint URLs when `skipDiscovery: true` is used. The supplied `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, `jwksEndpoint`, and `discoveryEndpoint` values are persisted without validating that the host is publicly routable. During the OIDC callback flow the server makes server-side HTTP requests to those stored URLs, enabling non-blind SSRF. When the plugin is configured with `trustEmailVerified: true`, a malicious `userInfo` response can claim `emailVerified: true` for any existing email, causing the attacker to be linked to (and receive a session for) the matching victim account.

## Impact

- **Package/component affected:** `npm:@better-auth/sso` (better-auth monorepo `packages/sso`)
- **Affected versions:** `>= 0.1.0, < 1.6.11` (also affects `1.7.0-beta.x` pre-releases)
- **Patched version:** `1.6.11`
- **Risk level:** Critical
- **Consequences:**
  1. **Server-Side Request Forgery (SSRF)** – any authenticated user can force the better-auth server to fetch arbitrary internal/private URLs during the OIDC callback.
  2. **Account Takeover (ATO)** – when `trustEmailVerified: true` is enabled, a forged `userInfo` payload can claim an arbitrary email is verified, causing the attacker to be logged in as the existing user with that email.

## Impact Parity

- **Disclosed/claimed maximum impact:** SSRF with possible account takeover when `trustEmailVerified: true`.
- **Reproduced impact from this run:** Full SSRF and account takeover were reproduced. The attacker-registered provider caused the server to hit `http://127.0.0.1:9876/token` and `http://127.0.0.1:9876/userinfo`, and the callback produced a session whose `user.email` was `victim@example.com`.
- **Parity:** `full`
- **Not demonstrated:** None. The reproduction reaches the disclosed endpoint surface and matches the claimed impact.

## Root Cause

The `POST /sso/register` endpoint (and `POST /sso/update-provider`) has a `skipDiscovery: true` branch that serializes the user-supplied `oidcConfig` object directly into the provider row without checking URL origins or host reachability. In `packages/sso/src/routes/sso.ts` (vulnerable `1.6.10`):

```ts
if (body.oidcConfig.skipDiscovery) {
  return JSON.stringify({
    ...body.oidcConfig,
    issuer: body.issuer,
  });
}
```

Later, the OIDC callback (`/sso/callback/:providerId`) uses the stored `config.tokenEndpoint` with `validateAuthorizationCode()` and `config.userInfoEndpoint` with `betterFetch()`, sending real HTTP requests to those URLs. Because the callback runs server-side, any URL (loopback, RFC 1918, cloud metadata) is reachable.

The `trustEmailVerified: true` option in `sso({ trustEmailVerified: true })` causes the callback to accept the `email_verified` claim from the attacker-controlled `userInfo` response, which then matches an existing user by email and links the attacker to that account via the OAuth auto-linking flow.

### Fix

The fix was introduced in PR [#9574](https://github.com/better-auth/better-auth/pull/9574) / commit `37f60cb176cb53147da7dfd5ec15afa5b486e81e` and released in `@better-auth/sso@1.6.11`. It adds a layered gate for all user-supplied OIDC URLs:

1. URL parsing + `http(s)` scheme requirement.
2. `isPublicRoutableHost` from `@better-auth/core/utils/host` (rejects loopback, RFC 1918, link-local, cloud-metadata FQDNs, etc.).
3. `trustedOrigins` allowlist as an escape hatch for private/internal IdPs.

In our reproduction the fixed version rejects the malicious registration with `BAD_REQUEST` and the `discovery_private_host` error code for `http://127.0.0.1:9876/authorize`.

## Reproduction Steps

1. **Reference script:** `bundle/repro/reproduction_steps.sh`
2. **What the script does:**
   - Creates two isolated Node.js workspaces in the durable project cache, one using `@better-auth/sso@1.6.10` (vulnerable) and one using `@better-auth/sso@1.6.11` (fixed).
   - In each workspace it writes a Vitest test that:
     - Starts a real better-auth HTTP server on `127.0.0.1:3000` using the `sso({ trustEmailVerified: true })` plugin.
     - Starts an attacker-controlled HTTP server on `127.0.0.1:9876` with `/authorize`, `/token`, `/userinfo`, and `/jwks` endpoints.
     - Creates a victim user (`victim@example.com`).
     - Signs in as the attacker test user.
     - Sends `POST /api/auth/sso/register` with `skipDiscovery: true` and attacker-controlled OIDC endpoints.
     - Initiates `POST /api/auth/sign-in/sso` and follows the redirect through the attacker `/authorize` endpoint back to the better-auth callback.
     - Verifies the callback succeeded and fetches `/token` and `/userinfo`, then validates the resulting session belongs to `victim@example.com`.
3. **Expected evidence of reproduction:**
   - **Vulnerable (`1.6.10`):** registration returns `200`, attacker logs show `POST /token` and `GET /userinfo`, callback redirects to `/dashboard`, and `/api/auth/get-session` returns `user.email === "victim@example.com"`.
   - **Fixed (`1.6.11`):** registration returns `400` with error code `discovery_private_host`, blocking the SSRF chain before the callback is ever reached.

## Evidence

- `bundle/logs/reproduction_steps.log` – high-level script output
- `bundle/logs/vuln-test.log` – full Vitest output for the vulnerable run
- `bundle/logs/fixed-test.log` – full Vitest output for the fixed run
- `bundle/repro/runtime_manifest.json` – structured runtime evidence manifest

Key excerpts from `bundle/logs/reproduction_steps.log` (vulnerable run):

```
register response: 200 {
  "tokenEndpoint": "http://127.0.0.1:9876/token",
  "userInfoEndpoint": "http://127.0.0.1:9876/userinfo",
  ...
}
callback status: 302 location: /dashboard
session: { "user": { "email": "victim@example.com", "emailVerified": true, ... } }
attacker requests: [
  { "url": "/token", "method": "POST" },
  { "url": "/userinfo", "method": "GET" }
]
```

Fixed run excerpt:

```
register response: 400 {
  "message": "The authorizationEndpoint URL (http://127.0.0.1:9876/authorize) is not publicly routable: 127.0.0.1...",
  "code": "discovery_private_host"
}
```

Environment captured:
- Node.js version: `v24.18.0`
- npm version: `11.16.0`
- Vulnerable package: `@better-auth/sso@1.6.10` + `better-auth@1.6.10`
- Fixed package: `@better-auth/sso@1.6.11` + `better-auth@1.6.11`

## Recommendations / Next Steps

- **Upgrade guidance:** Upgrade `@better-auth/sso` to `>= 1.6.11` (or the latest `better-auth` monorepo release). The patched release rejects private/internal OIDC URLs during provider registration unless explicitly allowlisted.
- **Configuration guidance:** If an internal IdP on a private host is required, add its origin to the `trustedOrigins` better-auth option rather than disabling validation.
- **Testing recommendations:**
  - Add regression tests that attempt `POST /sso/register` with loopback, RFC 1918, and cloud-metadata URLs and assert `discovery_private_host`.
  - Verify that `trustedOrigins` correctly allows legitimate internal IdPs.
  - Audit `POST /sso/update-provider` with the same URL set, as the fix covers both endpoints.

## Additional Notes

- **Idempotency:** The reproduction script was executed twice consecutively from a clean bundle and produced the same vulnerable/fixed divergence both times.
- **Limitations:** The reproduction uses `127.0.0.1` for the attacker endpoints to demonstrate the SSRF without needing an external network. The same vulnerable code path would allow arbitrary internal/cloud metadata targets in a production deployment.
- **Surface:** The reproduction exercises the real better-auth HTTP handler on a real TCP listener (`127.0.0.1:3000`), with real HTTP requests crossing the attacker (`127.0.0.1:9876`) and the better-auth callback boundary.
