PUT /api/global/users/tenant/owner (changeTenantOwnerEmail) reassigns the email of the tenant account holder (the top-privilege admin), saving with isAccountHolder:true, allowChangingEmail:true (deliberately bypassing the normal "cannot change the account-holder" protection). The route's only authorization is the cloudRestricted middleware, which is a no-op when SELF_HOSTED / DISABLE_ACCOUNT_PORTAL is set; the router-wide guard then requires only that the caller is authenticated. So any logged-in user — including the lowest-privilege BASIC app user — can repoint the admin's email to an attacker address, then run the public password-reset flow to fully take over the admin account.
curl -s -b basic.ck -X PUT localhost:10000/api/global/users/tenant/owner \
-H 'Content-Type: application/json' \
-d '{"originalEmail":"admin@poc.local","newAccountEmail":"attacker@evil.test","tenantIds":["default"]}'
Summary
PUT /api/global/users/tenant/owner (changeTenantOwnerEmail) reassigns the email of the tenant account holder (the top-privilege admin), saving with isAccountHolder:true, allowChangingEmail:true (deliberately bypassing the normal "cannot change the account-holder" protection). The route's only authorization is the cloudRestricted middleware, which is a no-op when SELF_HOSTED / DISABLE_ACCOUNT_PORTAL is set; the router-wide guard then requires only that the caller is authenticated. So any logged-in user — including the lowest-privilege BASIC app user — can repoint the admin's email to an attacker address, then run the public password-reset flow to fully take over the admin account.
Details
packages/worker/src/api/routes/global/users.ts:80-84—PUT /api/global/users/tenant/owneron thecloudRestrictedRoutesgroup.packages/worker/src/api/routes/endpointGroups/standard.ts—cloudRestrictedRoutes = group(cloudRestricted)(noadminOnly/role check).packages/worker/src/middleware/cloudRestricted.ts:9-26— theisValidInternalAPIKeycheck is wrapped inif (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL); in self-host it falls straight through tonext().packages/worker/src/api/index.ts(~line 160) requires onlyctx.isAuthenticated && ctx.user.budibaseAccess(every global user, any role).packages/worker/src/api/controllers/global/users.ts:109-141— takesoriginalEmail,newAccountEmail,tenantIdsfrom the request body; setstenantUser.email = newAccountEmail, clears SSO, savesisAccountHolder:true, allowChangingEmail:true.PoC
Result
OK.admin@poc.local→ 403; login asattacker@evil.test(now the admin account) → 200. This email-hijack primitive is what was demonstrated live — a BASIC user moved the top-privilege account-holder identity to an attacker-controlled address.attacker@evil.test. On the PoC instance this returned 400 because no SMTP was configured on the test box (resetcannot send the mail), so the reset→login leg was not executed end-to-end here. On any production self-host (SMTP configured — the normal state), the reset email lands in the attacker's mailbox → new password → full admin/account-holder takeover (global builder + admin → app source, all datasource credentials, all data, and builder-only server-side automation steps incl. the self-hostbash/executeScriptstep = server RCE). The completion is a generic SMTP-gated consequence of owning the account email, not a separate vulnerability — but it is flagged here as not-live-reproduced rather than claimed.Impact
Privilege escalation
Account take over
Admin account take over