+ "details": "### Summary\nBudibase'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.\n\n### Details\nThe OIDC verify callback extracts the email and never consults `email_verified`:\n- `packages/backend-core/src/middleware/passport/sso/oidc.ts:59` — `email: getEmail(profile, jwtClaims)`.\n- `getEmail` (`oidc.ts:113-135`) returns `profile._json.email` ->`jwtClaims.email` -> `preferred_username`. **No `email_verified` check.**\n- `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.\n\nThe email is then used as the **account-linking key**:\n- `sso.authenticate(...)` -> `packages/backend-core/src/middleware/passport/sso/sso.ts`:\n - `: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`).\n - `: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`).\n - `syncUser(...)` (`sso.ts:80,102-138`) spreads `...user`, preserving the victim `_id`/`tenantId`/`roles`; only overwrites provider fields.\n- `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.\n\nPer 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.\n\n**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:\n- **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.**\n- **Self-service profile editing** — many IdPs let a logged-in user change their own email without forced re-verification.\n- **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.\nIt 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.\n\n### PoC\nReproduced 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.\n\n**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.\n\n**Step 1 — the IdP asserts the claim (proves `email_verified=false`):**\n```\nPOST /realms/budi/protocol/openid-connect/token (Keycloak)\ngrant_type=password&client_id=budibase&client_secret=…&username=attacker&password=Attacker123!&scope=openid email profile\n-> id_token payload: { \"sub\":\"3cf58c45-…\", \"preferred_username\":\"attacker\",\n \"email\":\"victim@stand.local\", \"email_verified\":false }\n```\nThe authenticated principal is provably **`attacker`** (its own `sub`/`preferred_username`/password), merely *claiming* the victim's email, unverified.\n\n<img width=\"1484\" height=\"685\" alt=\"image\" src=\"https://github.com/user-attachments/assets/cdddac3c-b7c0-4c59-aaec-9092706a7ad8\" />\n\n\n**Step 2 — drive the standard OIDC flow** as `attacker`:\n`GET /api/global/auth/default/oidc/configs/kc-oidc-1` -> IdP login as `attacker`/`Attacker123!` -> `GET /api/global/auth/oidc/callback?code=…&state=…`.\n\n<img width=\"1145\" height=\"454\" alt=\"image\" src=\"https://github.com/user-attachments/assets/ac0a73d0-c5fc-4d0f-b9b0-95f6273b99f7\" />\n\n<img width=\"1172\" height=\"445\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a4b27a7e-4293-4caf-957f-d27c54ea461a\" />\n\n<img width=\"1157\" height=\"648\" alt=\"image\" src=\"https://github.com/user-attachments/assets/06699774-92b0-4163-a171-9a30bc877ecc\" />\n\n<img width=\"1201\" height=\"525\" alt=\"image\" src=\"https://github.com/user-attachments/assets/92a92fcf-9d29-42ca-921d-de6fbd78198f\" />\n\n\n\n**Step 3 — result (takeover):** Budibase sets `budibase:auth` to a session JWT\n`{ \"userId\":\"us_1ab2dfcf…\", \"email\":\"victim@stand.local\", \"tenantId\":\"default\" }`, and\n`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.\n\n<img width=\"1001\" height=\"456\" alt=\"image\" src=\"https://github.com/user-attachments/assets/c8dfc25f-aed7-4cd7-9323-f029e4d1a725\" />\n\n\n**Negative control (proves the email claim is the cause, not a normal self-login):**\nA second attacker `attacker2` with a **benign** unverified email `attacker2@evil.local` (matching no Budibase user) runs the *identical* flow:\n```\nid_token: { \"sub\":\"55794bf2-…\", \"preferred_username\":\"attacker2\", \"email\":\"attacker2@evil.local\", \"email_verified\":false }\n-> budibase:auth: { \"userId\":\"us_55794bf2-…\" } (a NEW account, _id derived from the IdP sub)\n-> /api/global/self: { \"_id\":\"us_55794bf2-…\", \"email\":\"attacker2@evil.local\", admin.global: null, builder.global: null }\n```\n<img width=\"1153\" height=\"489\" alt=\"image\" src=\"https://github.com/user-attachments/assets/8f6cb849-e6f3-49f1-b576-8aeba026a3be\" />\n\n<img width=\"1089\" height=\"466\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a892ec3e-3753-4bc0-9eee-9f0613b2d92e\" />\n\n<img width=\"826\" height=\"532\" alt=\"image\" src=\"https://github.com/user-attachments/assets/cc30bd9c-1bdb-4bea-934f-a8b3e228940e\" />\n\n\nWith 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.\n\n### Impact\nTakeover 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.\n\n### Remediation\n**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.\n\n**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.\n\n**Defense-in-depth for operators who cannot patch immediately:**\n- 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.\n- Prefer `sub`-based account mapping over email in the IdP/Budibase mapping config where available.\n- Audit existing accounts for unexpected OIDC links to privileged users; rotate sessions.",
0 commit comments