# Variant Root Cause Analysis: GHSA-5rr4-8452-hf4v (@better-auth/sso SSRF → DNS-shaped bypass)

## Summary

The 1.6.11 patch for GHSA-5rr4-8452-hf4v validates user-supplied OIDC endpoint URLs at `POST /sso/register` and `POST /sso/update-provider` using a literal hostname classification (`isPublicRoutableHost`). This variant demonstrates that the 1.6.11 fix is bypassable by registering a provider whose endpoints use a public-looking FQDN that resolves to a private IP address. The server-side callback still fetches the attacker-controlled token and userInfo endpoints, reproducing the SSRF. The latest release (`1.6.23`) closes this bypass with a runtime DNS-resolution check (`assertOIDCEndpointsResolvePublic`), confirming the gap in the 1.6.11 fix.

## Fix Coverage / Assumptions

The original fix relies on the invariant that a literal, publicly-routable hostname is safe to fetch. It covers:
- `POST /sso/register` and `POST /sso/update-provider` when `skipDiscovery: true`.
- All user-provided OIDC URLs: `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, `jwksEndpoint`, `discoveryEndpoint`.
- The non-skip-discovery branch via `discoverOIDCConfig` and `isTrustedOrigin`.

The fix does **not** resolve hostnames to IP addresses at registration time, nor does it re-validate the stored endpoints at fetch time. It trusts that a public-looking literal hostname will not route to an internal address.

## Variant / Alternate Trigger

- **Entry point:** same as the parent issue: `POST /api/auth/sso/register` with `skipDiscovery: true`, then `POST /api/auth/sign-in/sso`, then `/api/auth/sso/callback/:providerId`.
- **Different input:** instead of supplying literal internal IP addresses (`127.0.0.1`), the attacker supplies a hostname that is classified as a public FQDN but resolves to a private IP (e.g., the container hostname or an attacker-controlled public domain with a DNS record pointing to `127.0.0.1` / RFC 1918 / cloud metadata).
- **Code path involved:**
  - `packages/sso/src/routes/sso.ts` — `registerSSOProvider` calls `validateSkipDiscoveryEndpoints`.
  - `packages/sso/src/oidc/discovery.ts` — `validateSkipDiscoveryEndpoint` uses `isPublicRoutableHost(parsed.hostname)` only.
  - `packages/sso/src/routes/sso.ts` — `handleOIDCCallback` fetches `config.tokenEndpoint` via `validateAuthorizationCode` and `config.userInfoEndpoint` via `betterFetch` without re-validating the resolved address.
  - In `1.6.23`, the same path now calls `assertOIDCEndpointsResolvePublic` in `ensureRuntimeDiscovery`, which blocks the variant.

## Impact

- **Package/component affected:** `npm:@better-auth/sso` / `better-auth` monorepo `packages/sso`.
- **Affected versions tested:**
  - `1.6.10` — vulnerable baseline reproduced.
  - `1.6.11` — bypass reproduced (DNS-shaped SSRF).
  - `1.6.23` (latest) — blocked by the runtime DNS-resolution hardening.
- **Risk level:** High (the original SSRF impact is preserved on the 1.6.11 patch).
- **Consequences:**
  1. **Server-Side Request Forgery (SSRF)** — an authenticated attacker can force the better-auth server to fetch arbitrary internal/private URLs during the OIDC callback.
  2. **Account takeover is less reliable on 1.6.11** — the callback reaches the attacker endpoints, but the downstream OAuth auto-linking in the tested 1.6.11 configuration returns `error=account not linked` for the victim email. This is a downstream linking behavior change, not a failure of the SSRF primitive. On 1.6.10 the full account takeover still works with the original trigger.

## Impact Parity

- **Disclosed/claimed maximum impact:** SSRF with possible account takeover when `trustEmailVerified: true`.
- **Reproduced impact from this variant run:**
  - Confirmed SSRF on 1.6.11: the attacker server logged `POST /token` and `GET /userinfo` requests originating from the better-auth server to an internal/private IP.
  - The callback was reached and processed the attacker token/userinfo responses; the `discovery_private_host` validation did not block the fetch.
  - Full account takeover to `victim@example.com` was not reproduced on 1.6.11 with this hostname-based variant; the flow halted at `error=account not linked`. It was reproduced on the 1.6.10 baseline.
- **Parity:** `partial` — the SSRF primitive is preserved, but the account takeover chain is not guaranteed on the patched version due to an additional linking guard.
- **Not demonstrated:** Cloud-metadata or internal-service exfiltration beyond the local attacker server; arbitrary remote internal targets would be reachable in a production deployment where the attacker controls a public domain's DNS record.

## Root Cause

The same underlying root cause as the parent issue: attacker-controlled URLs are persisted as OIDC endpoints and then fetched server-side during the callback. The 1.6.11 fix added a literal-host check but not a DNS-resolution check, so the sink remains reachable through any public-looking hostname that resolves to an internal address. In `packages/sso/src/oidc/discovery.ts` (1.6.11):

```ts
function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
  const parsed = parseURL(name, endpoint);
  if (isPublicRoutableHost(parsed.hostname)) return;
  if (isTrustedOrigin(parsed.toString())) return;
  throw new DiscoveryError("discovery_private_host", ...);
}
```

`isPublicRoutableHost` returns `true` for an arbitrary FQDN such as the container hostname, because the literal name is not in any special-purpose range. Node.js then resolves that FQDN to a private IP when `fetch()` is issued during the callback, and the server connects to the attacker-controlled endpoint.

## Reproduction Steps

1. **Reference script:** `bundle/vuln_variant/reproduction_steps.sh`
2. **What the script does:**
   - Creates three isolated Node.js workspaces using `better-auth`/`@better-auth/sso` versions `1.6.10`, `1.6.11`, and `latest` (`1.6.23`).
   - In each workspace it writes a Vitest test that:
     - Starts a real better-auth server on `127.0.0.1:3000` with `sso({ trustEmailVerified: true })`.
     - Starts an attacker server on `0.0.0.0:9876` so it can be reached via the private IP the hostname resolves to.
     - Creates a victim user and signs in as an attacker test user.
     - Registers a malicious SSO provider with `skipDiscovery: true` and endpoints pointing at a public-looking hostname that resolves to a private IP (`http://<container-hostname>:9876/...`).
     - Initiates sign-in, follows the attacker authorization redirect, and visits the callback.
   - Verifies whether the better-auth server performed server-side fetches to the attacker `/token` and `/userinfo` endpoints (SSRF) and whether the callback was blocked by endpoint validation.
3. **Expected evidence:**
   - **Vulnerable (`1.6.10`):** registration returns `200`, attacker logs `POST /token` and `GET /userinfo`, callback redirects to `/dashboard`, and the resulting session belongs to `victim@example.com`.
   - **Fixed (`1.6.11`):** registration returns `200` (the hostname passes literal classification), attacker logs `POST /token` and `GET /userinfo` (SSRF confirmed), and the callback is reached. The downstream account-linking step may fail with `account not linked`, but the SSRF primitive is preserved.
   - **Latest (`1.6.23`):** registration returns `200`, but `/sign-in/sso` returns `400` with `code: discovery_private_host` and message indicating the hostname resolves to a non-public address (`172.20.0.4`). No server-side `/token` or `/userinfo` request is made.

## Evidence

- `bundle/logs/vuln_variant.log` — high-level script output.
- `bundle/logs/vuln-variant-vuln-test.log` — full Vitest output for the 1.6.10 baseline.
- `bundle/logs/vuln-variant-fixed-test.log` — full Vitest output for the 1.6.11 bypass run.
- `bundle/logs/vuln-variant-latest-test.log` — full Vitest output for the 1.6.23 block.
- `bundle/vuln_variant/runtime_manifest.json` — structured runtime evidence manifest.

Key excerpts from `bundle/logs/vuln_variant.log` (fixed run):

```
register response: 200 ...
authorizationEndpoint: "http://974542b9802b:9876/authorize",
tokenEndpoint: "http://974542b9802b:9876/token",
userInfoEndpoint: "http://974542b9802b:9876/userinfo",
...
callback status: 302 location: http://localhost:3000/api/auth/error?error=account not linked
attacker requests: [
  { "url": "/token", "method": "POST" },
  { "url": "/userinfo", "method": "GET" }
]
```

Latest run excerpt:

```
sign-in response: 400 {
  "message": "The tokenEndpoint host \"974542b9802b\" resolves to a non-publicly-routable address (172.20.0.4). ...",
  "code": "discovery_private_host"
}
```

Environment captured:
- Node.js: `v24.18.0`
- npm: `11.16.0`
- Tested fixed source commit: `37f60cb176cb53147da7dfd5ec15afa5b486e81e` (PR #9574, released as `@better-auth/sso@1.6.11`).
- Latest tested: `@better-auth/sso@1.6.23` / `better-auth@1.6.23`.
- Container hostname: `974542b9802b`, resolving to `172.20.0.4` (RFC 1918).

## Recommendations / Next Steps

- **Upgrade guidance:** The 1.6.11 fix is insufficient against DNS-shaped SSRF. Users should upgrade to `>= 1.6.23` (or the latest release) which performs runtime DNS resolution checks before fetching OIDC endpoints.
- **Backport recommendation:** For 1.6.11, add a callback-time `assertOIDCEndpointsResolvePublic` check (or equivalent) before every server-side fetch to `tokenEndpoint`, `userInfoEndpoint`, and `jwksEndpoint`.
- **Redirect hardening:** Set `redirect: "error"` on the token endpoint fetch in `validateAuthorizationCode` to prevent public endpoints from redirecting to internal addresses.
- **Pin internal IdPs correctly:** If a legitimate internal IdP is required, add its origin to `trustedOrigins` and keep the DNS-resolution check enabled; do not rely solely on the registration-time literal check.
- **Testing recommendations:** Add regression tests that register providers with hostnames resolving to loopback/RFC 1918 addresses (e.g., via local `/etc/hosts` or a mock DNS resolver) and assert that the callback is blocked.

## Additional Notes

- **Idempotency:** The reproduction script was executed twice consecutively from the same workspace and produced the same vulnerable/fixed/latest divergence both times.
- **Limitations:** The in-environment demonstration uses the container hostname, which resolves to the container’s own private IP. In a real attack, the attacker would use a public domain under their control with a DNS record pointing to an internal target. The same code path is exercised in both cases.
- **Trust boundary:** This is a real vulnerability because the malicious input is delivered over the authenticated `POST /sso/register` API and crosses the trust boundary from the attacker to the server; the server then makes unsolicited outbound requests to an internal/private address.
