# REPRO-2026-00274: @better-auth/sso <1.6.11 allows non-blind SSRF via unvalidated OIDC endpoint URLs during SSO provider registration, with potential account takeover when trustEmailVerified is enabled. ## Summary Status: published Severity: critical Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00274 GHSA: GHSA-5RR4-8452-HF4V CVE: CVE-2026-53513 ## Package Name: @better-auth/sso Ecosystem: npm Affected: >=0.1.0, <1.6.11 (also 1.7.0-beta.x) Fixed: 1.6.11 ## Root Cause # 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. ## Reproduction Details Reproduced: 2026-07-08T04:52:52.117Z Duration: 1849 seconds Tool calls: 281 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00274 pruva-verify GHSA-5RR4-8452-HF4V pruva-verify CVE-2026-53513 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00274&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00274/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - GitHub Advisory: https://github.com/advisories/GHSA-5RR4-8452-HF4V - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-53513 - Source: https://github.com/advisories/GHSA-5rr4-8452-hf4v ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 13416 bytes) - bundle/repro/rca_report.md (analysis, 7986 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 16228 bytes) - bundle/vuln_variant/rca_report.md (analysis, 10864 bytes) - bundle/artifact_promotion_manifest.json (other, 7059 bytes) - bundle/artifact_promotion_report.json (other, 9929 bytes) - bundle/vuln_variant/source_identity.json (other, 1228 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 1400 bytes) - bundle/logs/reproduction_steps.log (log, 3480 bytes) - bundle/logs/vuln-test.log (log, 3565 bytes) - bundle/logs/fixed-test.log (log, 2185 bytes) - bundle/repro/runtime_manifest.json (other, 720 bytes) - bundle/repro/validation_verdict.json (other, 926 bytes) - bundle/logs/vuln-variant-fixed-test.log (log, 3801 bytes) - bundle/logs/vuln-variant-latest-test.log (log, 2267 bytes) - bundle/logs/vuln_variant.log (log, 10074 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 931 bytes) - bundle/vuln_variant/variant_manifest.json (other, 3403 bytes) - bundle/vuln_variant/validation_verdict.json (other, 1346 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 6244 bytes) - bundle/logs/vuln-variant-vuln-test.log (log, 4540 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00274 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00274/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00274 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev