GHSA-hp6v-6jw7-gv2f Budibase: OIDC SSO account takeover: incoming identity linked by email without checking email_verified
SummaryBudibase's OIDC SSO login links an incoming SSO identity to an existing Budibase account **by email address alone**, without ever checking the `email_verified` claim of the OIDC ID token. Budibase first tries to match the IdP `sub`; when that misses (any fresh attacker IdP account) it silently falls back to matching by the `email` claim and **merges into the existing account by email**, preserving that account's `_id` and roles. Because the `email_verified` flag is never read, an attacker who can make a **configured/trusted** IdP emit a token carrying `email = <victim>` with `email_verified = false` is logged into Budibase **as the victim**, inheriting the victim's roles (including global admin/builder). Per OIDC Core §5.7 the `email` claim MUST NOT be used as an identity key unless `email_verified` is `true`; Budibase effectively delegates all account-linking trust to every configured IdP's email-verification policy while checking nothing itself. Full account takeover of any existing Budibase user, including the instance owner.DetailsThe OIDC verify callback extracts the email and never consults `email_verified`:`packages/backend-core/src/middleware/passport/sso/oidc.ts:59` — `email: getEmail(profile, jwtClaims)`.`getEmail` (`oidc.ts:113-135`) returns `profile._json.email` ->`jwtClaims.email` -> `preferred_username`. **No `email_verified` check.**`buildJwtClaims` (`oidc.ts:99-107`) assembles claims from `_json.email`/`emails[0].value` — no verification flag is read. `grep -r email_verified packages/` -> 0 hits.The email is then used as the **account-linking key**:`sso.authenticate(...)` -> `packages/backend-core/src/middleware/passport/sso/sso.ts`:`:38,44` `users.getById(generateGlobalUserID(details.userId))` keyed on the IdP `sub`; for a fresh attacker IdP account this 404s and is swallowed (`:45-54`).`:57-59` **fallback:** `dbUser = await users.getGlobalUserByEmail(details.email)` -> loads the **victim's** account (victim `_id` + roles) purely by email (`packages/backend-core/src/users/users.ts:100-124`, `USER_BY_EMAIL` view, no binding to the IdP `sub`).`syncUser(...)` (`sso.ts:80,102-138`) spreads `...user`, preserving the victim `_id`/`tenantId`/`roles`; only overwrites provider fields.`UserDB.save` (`packages/backend-core/src/users/db.ts:235`): because `ssoUser._id` is the victim's, the `_id` branch runs (`:253`), `getById(_id)` matches the victim (`:256`), the "Email address cannot be changed" guard (`:257-259`) does not fire (`dbUser.email === email`), and the `EmailUnavailableError` guard (`:269-275`) is skipped (it only runs in the `!dbUser` branch). The merge proceeds silently; a session JWT is issued for the victim.Per OIDC Core §5.7, the `email` claim MUST NOT be used as an identity key unless `email_verified` is `true`. Budibase never reads the flag.**Preconditions (attack requirement — `AT:P`):** the attacker must be able to authenticate through an IdP that the Budibase instance **trusts** AND get that IdP to assert the victim's email with `email_verified = false`. This is reachable, not exotic:**Self-registration with an unverified email — Keycloak and Authentik ship with *"Verify Email" OFF by default*; if the trusted IdP allows public sign-up, the attacker registers a new account and simply enters `email = <victim>` at sign-up. No confirmation email is needed — the IdP stores and asserts it unverified.****Self-service profile editing** — many IdPs let a logged-in user change their own email without forced re-verification.**Attacker-operated / federated IdP or permissive social login** — where the attacker controls or influences a trusted provider, or the provider asserts a user-typed (unverified) email.
It is **not** exploitable through a strict corporate IdP that enforces email verification (there `email_verified = true` and the attacker cannot claim the victim's address) — which is exactly why Budibase must check the flag rather than assume every configured IdP enforces it. The defect is unconditional on the Budibase side; the attack requirement is purely the (default, common) IdP email policy.PoCReproduced live on Budibase `3.39.14` (self-hosted, community license) against a stock **Keycloak 26** realm `budi` with default "Verify Email" = off; OIDC client registered and activated in Budibase.**Setup:** a pre-existing **victim** global-admin Budibase account `victim@stand.local` (`_id = us_1ab2dfcf…`, local account, *no IdP link*). The attacker owns their **own** IdP account (`attacker`, distinct `sub`) and can set its email attribute unverified.**Step 1 — the IdP asserts the claim (proves `email_verified=false`):**The authenticated principal is provably **`attacker`** (its own `sub`/`preferred_username`/password), merely *claiming* the victim's email, unverified.**Step 2 — drive the standard OIDC flow** as `attacker`:
`GET /api/global/auth/default/oidc/configs/kc-oidc-1` -> IdP login as `attacker`/`Attacker123!` -> `GET /api/global/auth/oidc/callback?code=…&state=…`.**Step 3 — result (takeover):** Budibase sets `budibase:auth` to a session JWT
`{ "userId":"us_1ab2dfcf…", "email":"victim@stand.local", "tenantId":"default" }`, and
`GET /api/global/self` returns the **victim**: `_id = us_1ab2dfcf…`, `admin.global = true`, `builder.global = true`, `providerType = oidc`. The attacker authenticated as a *different* IdP principal with an *unverified* email yet now holds a full global-admin session for the victim.**Negative control (proves the email claim is the cause, not a normal self-login):**
A second attacker `attacker2` with a **benign** unverified email `attacker2@evil.local` (matching no Budibase user) runs the *identical* flow:With a benign email the attacker gets **their own new low-privilege account**; only when the email claim equals the victim's does the same flow yield the **victim's admin account**. Same self-authentication, single variable changed = the unverified-email merge is the vulnerability.ImpactTakeover of any existing Budibase account by email, including the instance owner / global admin -> full control of the tenant (apps, datasources, automations, user management, stored datasource credentials). The attacker authenticates as their *own* (different) IdP principal and ends up holding the victim's session and roles. The only requirement beyond a trusted IdP login is that the IdP assert the victim's email unverified — the default for a freshly-created Keycloak/Authentik realm and common in social logins (see Preconditions). Any deployment that trusts an OIDC IdP without enforced email verification is exposed; the Budibase-side flaw (ignoring `email_verified`) is unconditional.Remediation**Primary fix:** in the OIDC verify path, require `email_verified === true` before using `email` to look up / link an existing account; otherwise reject the login (or fall back to `sub`-only matching and never merge into a pre-existing local/SSO account). Concretely, thread the `email_verified` claim through `buildJwtClaims`/`getEmail` (`oidc.ts`) and gate the `getGlobalUserByEmail` fallback in `sso.ts:57-59` on it.**Audit the whole class:** apply the same `email_verified` (and, for SAML, `EmailVerified`/assertion-signature) gate to every SSO strategy that links by email — OIDC, SAML, and any social provider — not only the Google strategy (which already passes `requireLocalAccount=true`). Email-based account linking anywhere must require a verified email.**Defense-in-depth for operators who cannot patch immediately:**On the IdP, enable "Verify Email" / require verified email before issuing tokens (Keycloak: realm -> Login -> Verify Email = on), and restrict which email domains the IdP will assert.Prefer `sub`-based account mapping over email in the IdP/Budibase mapping config where available.Audit existing accounts for unexpected OIDC links to privileged users; rotate sessions.
CVSS v4.0 score: 9