# Patch Analysis: GHSA-5rr4-8452-hf4v (@better-auth/sso SSRF)

## What the fix changes

The patch was introduced in commit `37f60cb176cb53147da7dfd5ec15afa5b486e81e` (PR #9574) and released in `@better-auth/sso@1.6.11` / `better-auth@1.6.11`. It is a registration/update-time gate for attacker-supplied OIDC endpoint URLs.

### Changed files (as observed in the 1.6.11 source)
- `packages/sso/src/oidc/discovery.ts` — added validation helpers:
  - `parseURL(name, endpoint)` — validates URL shape and enforces `http:`/`https:` scheme.
  - `validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin)` — layered check:
    1. URL parsing + scheme.
    2. `isPublicRoutableHost(parsed.hostname)` from `@better-auth/core/utils/host`.
    3. `isTrustedOrigin(parsed.toString())` operator allowlist escape hatch.
  - `validateSkipDiscoveryEndpoints(config, isTrustedOrigin)` — iterates over `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, `jwksEndpoint`, and `discoveryEndpoint`.
- `packages/sso/src/routes/sso.ts` — `registerSSOProvider` calls `validateSkipDiscoveryEndpoints(body.oidcConfig, ...)` before persisting the provider.
- `packages/sso/src/routes/providers.ts` — `updateSSOProvider` calls the same validator on `body.oidcConfig`.
- The non-skip-discovery branch uses `discoverOIDCConfig`, which validates the discovery URL and the discovered endpoints against `isTrustedOrigin`.

### What the fix explicitly covers
- All user-supplied OIDC URLs in the `skipDiscovery: true` path are rejected if their literal hostname is:
  - loopback (`127.0.0.0/8`, `::1`)
  - RFC 1918 private (`10/8`, `172.16/12`, `192.168/16`)
  - link-local / shared-address space / cloud-metadata FQDNs (`169.254.169.254`, `metadata.google.internal`, etc.)
  - other RFC 6890 special-purpose ranges
- Both `POST /sso/register` and `POST /sso/update-provider` are covered.
- The non-skip branch is tightened to `trustedOrigins` only.

## What the fix assumes

The 1.6.11 fix makes two key assumptions that the variant analysis tested:

1. **Literal hostname is a sufficient proxy for reachability.** It does not resolve the hostname to an IP address before classifying it as safe. Any FQDN that passes the literal public-routable check is allowed, even if its DNS record points to a loopback or RFC 1918 address.
2. **Validation at registration time is enough.** It does not re-validate the stored endpoint URLs at fetch time (during `/sign-in/sso` or `/sso/callback/:providerId`). Once a provider row is persisted, the callback flow trusts the stored URLs.

These assumptions are explicitly acknowledged in the source code comments of `@better-auth/core/utils/host` (the `isPublicRoutableHost` JSDoc notes: *“No DNS resolution: a public-looking FQDN that resolves to a private IP passes this check.”*)

## What the fix does NOT cover (gaps)

1. **DNS-shaped SSRF bypass.** A public-looking FQDN (e.g., the container hostname or an attacker-controlled public domain) that resolves to an internal/private IP is accepted at registration and then fetched server-side during the callback. This is the variant confirmed in this run.
2. **HTTP redirect SSRF.** The token endpoint is fetched via `validateAuthorizationCode` which does not set `redirect: "error"`. A public token endpoint that returns an HTTP 302 to an internal address could be followed by `betterFetch` / Node fetch, although the userInfo endpoint fetch explicitly disables redirects.
3. **Time-of-check/time-of-use DNS rebinding.** Even if a hostname resolves to a public IP at registration, a second lookup at callback time could resolve to a private IP.
4. **No callback-time re-validation.** In 1.6.11, `ensureRuntimeDiscovery` in the callback only hydrates missing endpoints; it does not re-run the public-routable check on stored URLs before they are fetched.

## Comparison: before vs. after the fix

| Scenario | 1.6.10 (vulnerable) | 1.6.11 (fixed) | 1.6.23 (latest) |
|---|---|---|---|
| `127.0.0.1` endpoints | accepted, SSRF | rejected: `discovery_private_host` | rejected: `discovery_private_host` |
| Public FQDN resolving to private IP | accepted, SSRF | **accepted, SSRF** (variant) | rejected: `discovery_private_host` via `assertOIDCEndpointsResolvePublic` |
| Public FQDN resolving to public IP | accepted, expected | accepted, expected | accepted if trusted |

## Is the fix complete?

The 1.6.11 fix is **not complete** against DNS-shaped SSRF. It closes the most obvious vector (literal internal IP addresses) but leaves the broader class of SSRF reachable via public-looking hostnames that resolve to internal addresses.

The project later added a second-layer defense in `1.6.23`:
- `assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin)` resolves the hostname with `dns.lookup` and rejects any resolved address that is not publicly routable.
- `assertOIDCEndpointsResolvePublic(config, isTrustedOrigin)` is called from `ensureRuntimeDiscovery` at both `/sign-in/sso` and `/sso/callback/:providerId` time, covering `tokenEndpoint`, `userInfoEndpoint`, and `jwksEndpoint`.

That later change closes the confirmed variant. The 1.6.11 fix alone remains bypassable.

## Target threat model / security policy

The project’s `SECURITY.md` only states that older versions are unsupported and the latest version is supported. There is no explicit statement that SSRF via DNS is out of scope. The `isPublicRoutableHost` documentation explicitly lists its limitations as known risks, indicating the project treats DNS-resolved SSRF as a real concern that the 1.6.23 follow-up addresses.

## Recommendation

Backport (or ensure consumers upgrade to) the runtime DNS-resolution checks introduced in `assertOIDCEndpointsResolvePublic` / `assertEndpointResolvesPublic`. For 1.6.11 specifically, the fix should be extended to:
- Re-validate every server-side-fetched OIDC endpoint (token, userInfo, jwks) at fetch time, not just at registration time.
- Resolve the hostname and classify the resolved IP with `isPublicRoutableHost` before connecting.
- Continue treating `trustedOrigins` as the explicit escape hatch for internal IdPs.
- Set `redirect: "error"` on the token endpoint fetch in `validateAuthorizationCode` to prevent redirect-based SSRF.
