---
id: GHSA-j9fc-w3mr-x6mv
title: ' Budibase: Privilege escalation via public role assignment API missing app-level authorization'
summary: ' Budibase: Privilege escalation via public role assignment API missing app-level authorization'
severity: high
cvss: 8.8
cwe:
  - CWE-269
  - CWE-862
  - CWE-863
vendor: budibase
product: '@budibase/server'
ecosystem: npm
affected:
  - '@budibase/server <= 3.38.1'
published: '2026-07-24'
updated: '2026-07-24'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-j9fc-w3mr-x6mv'
references:
  - url: >-
      https://github.com/Budibase/budibase/security/advisories/GHSA-j9fc-w3mr-x6mv
  - url: 'https://github.com/Budibase/budibase/releases/tag/3.40.0'
  - url: 'https://github.com/advisories/GHSA-j9fc-w3mr-x6mv'
tags:
  - ghsa
  - npm
ingestedAt: '2026-07-24T21:39:14.469Z'
---

## Overview

### 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 checks `roleUpdate.admin` (requires `isAdmin`) and `roleUpdate.builder` (requires `isGlobalBuilder`). The `GlobalRoleUpdate` interface declares only `{ builder?, admin? }`; `appBuilder` and `role` are not referenced.
- `packages/server/src/api/controllers/public/roles.ts` — `assign()` does `const { userIds, ...assignmentProps } = ctx.request.body; validateGlobalRoleUpdate(ctx, assignmentProps); await sdk.publicApi.roles.assign(userIds, assignmentProps)`. The `appBuilder`/`role` props pass through unvalidated.
- `packages/pro/src/sdk/publicApi/roles.ts` — `assign()`: for `opts.appBuilder` it sets `user.builder = { apps: existing.concat([getProdWorkspaceID(opts.appBuilder.appId)]) }`; for `opts.role` it sets `user.roles[getProdWorkspaceID(opts.role.appId)] = opts.role.roleId`. No check that the caller builds the target app, and `userIds` is an arbitrary list (`bulkGet`). The only gate is the `isExpandedPublicApiEnabled()` license check.
- `packages/server/src/api/routes/public/index.ts` — `applyAdminRoutes(roleEndpoints)` attaches **only** `middleware.builderOrAdmin` (no `publicApi`, no `authorized(PermissionType.USER, …)`).
- `packages/backend-core/src/middleware/builderOrAdmin.ts` — with a `workspaceId` present, it only requires `isBuilder(ctx.user, workspaceId)`. The attacker sets `x-budibase-app-id` to **their own** app (appA), so the gate passes.
- `packages/backend-core/src/middleware/builderOnly.ts` — on the worker, `POST /api/global/self/api_key` only requires `hasBuilderPermissions(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` — `isGlobalBuilder` is false for app-scoped builders (so the global `builder` flag is correctly blocked), while `isBuilder(user, appA)` and `hasBuilderPermissions(user)` are true.

Attack flow:

1. Attacker is an app-scoped builder of `appA` only (no global builder/admin).
2. `POST /api/global/self/api_key` (worker) — passes `builderOnly` via `hasBuilderPermissions` → attacker obtains a public API key.
3. `POST /api/public/v1/roles/assign` with header `x-budibase-app-id: <appA prod id>` and body `{"userIds":["<self>"],"appBuilder":{"appId":"<appB>"}}` — `builderOrAdmin` passes (`isBuilder(user, appA)`), `validateGlobalRoleUpdate` ignores `appBuilder`, the SDK pushes `appB` into `user.builder.apps`.
4. Attacker is now a builder of `appB` (and, via the `role` vector, can set any data-role such as `ADMIN` in any app).

Security boundary crossed:

- **Before:** builder of `appA` only.
- **After:** builder of `appB` (and any other app) → read/modify all rows, read datasource configs and exfiltrate stored datasource credentials, edit automations (including the `bash`/`executeScript`/`executeQuery` steps); 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`, commit `03fbabae4`
- 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):

1. Run the verification harness:

```bash
cd D:/CVE-Hunting/budibase-audit-output/lab
node harness/verify-privesc-authz.cjs
```

2. Observed result (evidence: `evidence/lab-privesc-authz.log`):

```text
[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`):

```bash
# 1) self-issue API key
curl -X POST http://localhost:10000/api/global/self/api_key -H "Cookie: <attacker session>" -d '{}'
# 2) escalate: grant self builder of appB
curl -X POST http://localhost:10000/api/public/v1/roles/assign \
  -H "x-budibase-api-key: <KEY>" -H "x-budibase-app-id: <appA prod id>" \
  -H "content-type: application/json" \
  -d '{"userIds":["<self global id>"],"appBuilder":{"appId":"<appB>"}}'
```

3. Expected result:

```text
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.log`
- `poc/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

[Budibase-GHSA-Reports.zip](https://github.com/user-attachments/files/29161435/Budibase-GHSA-Reports.zip)

### 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 packages

- `@budibase/server <= 3.38.1`

## Remediation

Refer to the advisory for the patched release.
