Budibase: Privilege escalation via public role assignment API missing app-level authorization
Description
Summary
Budibase 3.39.19 (commit 03fbabae4) is affected by a privilege-escalation / missing-authorization flaw in the public role-assignment API. An app-scoped builder (a user who builds only specific apps — user.builder.apps = [appA], not a global builder or admin) can grant themselves builder access to ANY other app in the tenant, or assign themselves/any user an arbitrary data-plane role (e.g. ADMIN) in any app, by calling POST /api/public/v1/roles/assign. The endpoint only authorizes the two *global* flags (admin, builder); the per-app appBuilder and role:{appId,roleId} grant vectors are passed to the backend without any authorization check that the caller controls the target app. Reproduced in a local authorized lab using the verbatim authorization-gate and SDK logic.
This is an incomplete fix of the role-assignment hardening in commit d63d1d9054 ("Inline public user global role validation"), which only ever validated the global flags.
Details
The issue is caused by authorization being implemented as a flag-level allowlist (admin/builder) instead of validating the *scope* the caller is granting.
Relevant code paths:
packages/server/src/api/controllers/public/globalRoleValidation.ts—validateGlobalRoleUpdate(ctx, roleUpdate)only checksroleUpdate.admin(requiresisAdmin) androleUpdate.builder(requiresisGlobalBuilder). TheGlobalRoleUpdateinterface declares only{ builder?, admin? };appBuilderandroleare not referenced.packages/server/src/api/controllers/public/roles.ts—assign()doesconst { userIds, ...assignmentProps } = ctx.request.body; validateGlobalRoleUpdate(ctx, assignmentProps); await sdk.publicApi.roles.assign(userIds, assignmentProps). TheappBuilder/roleprops pass through unvalidated.packages/pro/src/sdk/publicApi/roles.ts—assign(): foropts.appBuilderit setsuser.builder = { apps: existing.concat([getProdWorkspaceID(opts.appBuilder.appId)]) }; foropts.roleit setsuser.roles[getProdWorkspaceID(opts.role.appId)] = opts.role.roleId. No check that the caller builds the target app, anduserIdsis an arbitrary list (bulkGet). The only gate is theisExpandedPublicApiEnabled()license check.packages/server/src/api/routes/public/index.ts—applyAdminRoutes(roleEndpoints)attaches onlymiddleware.builderOrAdmin(nopublicApi, noauthorized(PermissionType.USER, …)).packages/backend-core/src/middleware/builderOrAdmin.ts— with aworkspaceIdpresent, it only requiresisBuilder(ctx.user, workspaceId). The attacker setsx-budibase-app-idto their own app (appA), so the gate passes.packages/backend-core/src/middleware/builderOnly.ts— on the worker,POST /api/global/self/api_keyonly requireshasBuilderPermissions(ctx.user), which is true for app-scoped builders, so the attacker can self-issue a public API key.packages/shared-core/src/sdk/documents/users.ts—isGlobalBuilderis false for app-scoped builders (so the globalbuilderflag is correctly blocked), whileisBuilder(user, appA)andhasBuilderPermissions(user)are true.
Attack flow:
- Attacker is an app-scoped builder of
appAonly (no global builder/admin). POST /api/global/self/api_key(worker) — passesbuilderOnlyviahasBuilderPermissions→ attacker obtains a public API key.POST /api/public/v1/roles/assignwith headerx-budibase-app-id:and body{"userIds":[""],"appBuilder":{"appId":""}}—builderOrAdminpasses (isBuilder(user, appA)),validateGlobalRoleUpdateignoresappBuilder, the SDK pushesappBintouser.builder.apps.- Attacker is now a builder of
appB(and, via therolevector, can set any data-role such asADMINin any app).
Security boundary crossed:
- Before: builder of
appAonly. - After: builder of
appB(and any other app) → read/modify all rows, read datasource configs and exfiltrate stored datasource credentials, edit automations (including thebash/executeScript/executeQuerysteps); plus arbitrary data-role assignment in any app. - Why disallowed: the per-app builder model is meant to isolate builders to their assigned apps; a builder of one app must not gain authority over apps they were never granted.
PoC
Environment:
- Budibase version:
3.39.19, commit03fbabae4 - Deployment: Business/Enterprise license required (
isExpandedPublicApiEnabled) - Attacker role: app-scoped builder (
user.builder.apps=[appA]), not global builder/admin - Mock services: none needed for the unit-level proof; HTTP PoC script provided for a licensed lab
Steps (unit-level proof, no license needed — verbatim auth-gate + SDK logic):
- Run the verification harness:
cd D:/CVE-Hunting/budibase-audit-output/lab
node harness/verify-privesc-authz.cjs
- Observed result (evidence:
evidence/lab-privesc-authz.log):
[PASS-VALIDATION] appBuilder:{appId:APP_B} -> NOT rejected
[PASS-VALIDATION] role:{appId:APP_B, roleId:'ADMIN'}-> NOT rejected
[BLOCKED 403] builder:true (global) -> Only global builders or admins ...
[BLOCKED 403] admin:true (global) -> Only global admins ...
after : builder={"apps":["app_A...","app_B..."]} roles={"app_B...":"ADMIN"}
isBuilder(attacker, APP_B) now = true <-- escalated to builder of APP_B
Steps (HTTP PoC for a licensed lab — poc/privesc-roles-assign.sh):
# 1) self-issue API key
curl -X POST http://localhost:10000/api/global/self/api_key -H "Cookie: " -d '{}'
# 2) escalate: grant self builder of appB
curl -X POST http://localhost:10000/api/public/v1/roles/assign \
-H "x-budibase-api-key: " -H "x-budibase-app-id: " \
-H "content-type: application/json" \
-d '{"userIds":[""],"appBuilder":{"appId":""}}'
- Expected result:
The request should be rejected (403) — an app-scoped builder must not be able to grant
itself builder/role access to an app it does not control. Instead it returns 200 and the
grant is applied.
Evidence files:
evidence/lab-privesc-authz.logpoc/verify-privesc-authz.cjs,poc/privesc-roles-assign.sh
Runtime limitation: full HTTP end-to-end requires a Business/Enterprise license (isExpandedPublicApiEnabled); no license was cracked. The authorization defect is proven at unit level with the verbatim validation + SDK logic, and the route→validation→SDK call chain was independently verified by source review.
Source
PoC
Impact
An attacker holding an app-scoped builder role on a single app in a licensed (Business/Enterprise) tenant can:
- escalate to builder of every other app in the tenant (cross-app/workspace isolation bypass);
- read and modify all rows in those apps;
- read those apps' datasource configurations and exfiltrate stored datasource credentials;
- edit automations in those apps, including server-side execution steps;
- assign arbitrary data-plane roles (e.g.
ADMIN) in any app to any user, including themselves.
It does not grant the global admin/builder flags (those are validated), so it is not a direct global-admin takeover; but cross-app builder is effectively a tenant-wide app/data-plane compromise. No real secrets were accessed during testing; the lab used canary-only data.
Affected products
1Patches
Vulnerability mechanics
References
3News mentions
0No linked articles in our index yet.