{"repro_id":"REPRO-2026-00274","version":6,"title":"@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.","repro_type":"security","status":"published","severity":"critical","cvss_score":9.6,"description":"The @better-auth/sso plugin accepts attacker-controlled OIDC endpoint URLs when `skipDiscovery: true` during SSO provider registration or update. These URLs are persisted without validation and later fetched server-side during OIDC callbacks, enabling non-blind SSRF. If `trustEmailVerified: true` is enabled, attacker-controlled userInfo responses can trigger account linking for an existing email, leading to account takeover.","root_cause":"# Root Cause Analysis: GHSA-5rr4-8452-hf4v (@better-auth/sso SSRF → account takeover)\n\n## Summary\n\n`@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.\n\n## Impact\n\n- **Package/component affected:** `npm:@better-auth/sso` (better-auth monorepo `packages/sso`)\n- **Affected versions:** `>= 0.1.0, < 1.6.11` (also affects `1.7.0-beta.x` pre-releases)\n- **Patched version:** `1.6.11`\n- **Risk level:** Critical\n- **Consequences:**\n  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.\n  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.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** SSRF with possible account takeover when `trustEmailVerified: true`.\n- **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`.\n- **Parity:** `full`\n- **Not demonstrated:** None. The reproduction reaches the disclosed endpoint surface and matches the claimed impact.\n\n## Root Cause\n\nThe `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`):\n\n```ts\nif (body.oidcConfig.skipDiscovery) {\n  return JSON.stringify({\n    ...body.oidcConfig,\n    issuer: body.issuer,\n  });\n}\n```\n\nLater, 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.\n\nThe `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.\n\n### Fix\n\nThe 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:\n\n1. URL parsing + `http(s)` scheme requirement.\n2. `isPublicRoutableHost` from `@better-auth/core/utils/host` (rejects loopback, RFC 1918, link-local, cloud-metadata FQDNs, etc.).\n3. `trustedOrigins` allowlist as an escape hatch for private/internal IdPs.\n\nIn 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`.\n\n## Reproduction Steps\n\n1. **Reference script:** `bundle/repro/reproduction_steps.sh`\n2. **What the script does:**\n   - 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).\n   - In each workspace it writes a Vitest test that:\n     - Starts a real better-auth HTTP server on `127.0.0.1:3000` using the `sso({ trustEmailVerified: true })` plugin.\n     - Starts an attacker-controlled HTTP server on `127.0.0.1:9876` with `/authorize`, `/token`, `/userinfo`, and `/jwks` endpoints.\n     - Creates a victim user (`victim@example.com`).\n     - Signs in as the attacker test user.\n     - Sends `POST /api/auth/sso/register` with `skipDiscovery: true` and attacker-controlled OIDC endpoints.\n     - Initiates `POST /api/auth/sign-in/sso` and follows the redirect through the attacker `/authorize` endpoint back to the better-auth callback.\n     - Verifies the callback succeeded and fetches `/token` and `/userinfo`, then validates the resulting session belongs to `victim@example.com`.\n3. **Expected evidence of reproduction:**\n   - **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\"`.\n   - **Fixed (`1.6.11`):** registration returns `400` with error code `discovery_private_host`, blocking the SSRF chain before the callback is ever reached.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` – high-level script output\n- `bundle/logs/vuln-test.log` – full Vitest output for the vulnerable run\n- `bundle/logs/fixed-test.log` – full Vitest output for the fixed run\n- `bundle/repro/runtime_manifest.json` – structured runtime evidence manifest\n\nKey excerpts from `bundle/logs/reproduction_steps.log` (vulnerable run):\n\n```\nregister response: 200 {\n  \"tokenEndpoint\": \"http://127.0.0.1:9876/token\",\n  \"userInfoEndpoint\": \"http://127.0.0.1:9876/userinfo\",\n  ...\n}\ncallback status: 302 location: /dashboard\nsession: { \"user\": { \"email\": \"victim@example.com\", \"emailVerified\": true, ... } }\nattacker requests: [\n  { \"url\": \"/token\", \"method\": \"POST\" },\n  { \"url\": \"/userinfo\", \"method\": \"GET\" }\n]\n```\n\nFixed run excerpt:\n\n```\nregister response: 400 {\n  \"message\": \"The authorizationEndpoint URL (http://127.0.0.1:9876/authorize) is not publicly routable: 127.0.0.1...\",\n  \"code\": \"discovery_private_host\"\n}\n```\n\nEnvironment captured:\n- Node.js version: `v24.18.0`\n- npm version: `11.16.0`\n- Vulnerable package: `@better-auth/sso@1.6.10` + `better-auth@1.6.10`\n- Fixed package: `@better-auth/sso@1.6.11` + `better-auth@1.6.11`\n\n## Recommendations / Next Steps\n\n- **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.\n- **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.\n- **Testing recommendations:**\n  - Add regression tests that attempt `POST /sso/register` with loopback, RFC 1918, and cloud-metadata URLs and assert `discovery_private_host`.\n  - Verify that `trustedOrigins` correctly allows legitimate internal IdPs.\n  - Audit `POST /sso/update-provider` with the same URL set, as the fix covers both endpoints.\n\n## Additional Notes\n\n- **Idempotency:** The reproduction script was executed twice consecutively from a clean bundle and produced the same vulnerable/fixed divergence both times.\n- **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.\n- **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.\n","ghsa_id":"GHSA-5RR4-8452-HF4V","cve_id":"CVE-2026-53513","cwe_id":"CWE-20","source_url":"https://github.com/advisories/GHSA-5rr4-8452-hf4v","package":{"name":"@better-auth/sso","ecosystem":"npm","affected_versions":">=0.1.0, <1.6.11 (also 1.7.0-beta.x)","fixed_version":"1.6.11"},"reproduced_at":"2026-07-08T04:52:52.117105+00:00","duration_secs":1849.0,"tool_calls":281,"handoffs":2,"total_cost_usd":5.193861680000001,"agent_costs":{"hypothesis_generator":0.0143845,"judge":0.343304,"repro":1.54415759,"support":0.0277866,"vuln_variant":3.2642289900000003},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/kimi-k2p7-code":0.0143845},"judge":{"gpt-5.5":0.343304},"repro":{"accounts/fireworks/models/kimi-k2p7-code":1.54415759},"support":{"accounts/fireworks/models/kimi-k2p7-code":0.0277866},"vuln_variant":{"accounts/fireworks/models/kimi-k2p7-code":3.2642289900000003}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-08T04:53:13.409427+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":13416,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7986,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16228,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":10864,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":7059,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":9929,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1228,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1400,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":3480,"category":"log"},{"path":"bundle/logs/vuln-test.log","filename":"vuln-test.log","size":3565,"category":"log"},{"path":"bundle/logs/fixed-test.log","filename":"fixed-test.log","size":2185,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":720,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":926,"category":"other"},{"path":"bundle/logs/vuln-variant-fixed-test.log","filename":"vuln-variant-fixed-test.log","size":3801,"category":"log"},{"path":"bundle/logs/vuln-variant-latest-test.log","filename":"vuln-variant-latest-test.log","size":2267,"category":"log"},{"path":"bundle/logs/vuln_variant.log","filename":"vuln_variant.log","size":10074,"category":"log"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":931,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3403,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1346,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6244,"category":"documentation"},{"path":"bundle/logs/vuln-variant-vuln-test.log","filename":"vuln-variant-vuln-test.log","size":4540,"category":"log"}]}