Résumé

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

Détails de l’avis

Summary

Budibase'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.

Details

The OIDC verify callback extracts the email and never consults email_verified:

  • packages/backend-core/src/middleware/passport/sso/oidc.ts:59email: 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.

PoC

Reproduced 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):

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 }

The authenticated principal is provably attacker (its own sub/preferred_username/password), merely claiming the victim's email, unverified.

image

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=….

image image image image

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.

image

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:

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 }
image image image

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.

Impact

Takeover of any existing Budibase account by email, including the instance

Références