Budibase Vulnerable to Remote Code Execution via Unsafe eval() in View Filter Map Function (Budibase Cloud)
Description
Budibase is a low code platform for creating internal tools, workflows, and admin panels. Prior to version 3.30.4, an 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 INTERNAL_API_KEY, JWT_SECRET, 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. Version 3.30.4 contains a patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
budibasenpm | < 3.30.4 | 3.30.4 |
Affected products
1Patches
1348659810cf9Merge pull request #18087 from Budibase/fix/views-security-issue
5 files changed · +116 −44
packages/server/src/api/controllers/view/tests/viewBuilder.spec.ts+37 −0 modified@@ -49,6 +49,43 @@ describe("viewBuilder", () => { }, }) }) + + it("escapes filter keys and values in generated expressions", () => { + const payload = `x" ); globalThis.pwned = true; ("` + const key = `Name"] ; globalThis.pwned = true; //` + + const view = viewTemplate({ + field: "myField", + tableId: "tableId", + filters: [ + { + value: payload, + condition: "EQUALS", + key, + }, + ], + }) + + expect(view.map).toContain( + `doc["Name\\"] ; globalThis.pwned = true; //"] === "x\\" ); globalThis.pwned = true; (\\""` + ) + }) + + it("throws for unknown filter conditions", () => { + expect(() => + viewTemplate({ + field: "myField", + tableId: "tableId", + filters: [ + { + value: "test", + condition: "BAD_TOKEN" as any, + key: "Name", + }, + ], + }) + ).toThrow("Invalid filter condition") + }) }) describe("Calculate", () => {
packages/server/src/api/controllers/view/utils.ts+6 −2 modified@@ -15,6 +15,10 @@ import { import env from "../../../environment" import viewBuilder from "./viewBuilder" +function getGroupByMulti(meta: any): boolean { + return meta?.groupByMulti ?? meta?.schema?.group?.type === "array" +} + export async function getView(viewName: string) { const db = context.getWorkspaceDB() if (env.SELF_HOSTED) { @@ -143,7 +147,7 @@ export async function migrateToInMemoryView(db: Database, viewName: string) { throw new Error("Unable to migrate view - no metadata") } // run the view back through the view builder to update it - const view = viewBuilder(meta) + const view = viewBuilder(meta, getGroupByMulti(meta)) delete designDoc.views?.[viewName] await db.put(designDoc) await saveView(null, viewName, view) @@ -159,7 +163,7 @@ export async function migrateToDesignView(db: Database, viewName: string) { if (!designDoc.views) { designDoc.views = {} } - designDoc.views[viewName] = viewBuilder(meta) + designDoc.views[viewName] = viewBuilder(meta, getGroupByMulti(meta)) await db.put(designDoc) await db.remove(view._id!, view._rev) }
packages/server/src/api/controllers/view/viewBuilder.ts+27 −13 modified@@ -18,12 +18,25 @@ const CONDITIONS: Record<string, string> = { CONTAINS: "CONTAINS", } +function tokenOrThrow(type: "condition" | "conjunction", token?: string) { + const mappedToken = token ? TOKEN_MAP[token] : undefined + if (!mappedToken) { + throw new Error(`Invalid filter ${type}: ${token}`) + } + return mappedToken +} + +function docKeyExpression(key: string) { + return `doc[${JSON.stringify(key)}]` +} + function isEmptyExpression(key: string) { + const docExpression = docKeyExpression(key) return `( - doc["${key}"] === undefined || - doc["${key}"] === null || - doc["${key}"] === "" || - (Array.isArray(doc["${key}"]) && doc["${key}"].length === 0) + ${docExpression} === undefined || + ${docExpression} === null || + ${docExpression} === "" || + (Array.isArray(${docExpression}) && ${docExpression}.length === 0) )` } @@ -88,24 +101,24 @@ function parseFilterExpression(filters: ViewFilter[]) { let first = true for (let filter of filters) { if (!first && filter.conjunction) { - expression.push(TOKEN_MAP[filter.conjunction]) + expression.push(tokenOrThrow("conjunction", filter.conjunction)) } if (filter.condition === CONDITIONS.CONTAINS) { + const condition = tokenOrThrow("condition", filter.condition) + const valueLiteral = JSON.stringify(filter.value) expression.push( - `doc["${filter.key}"].${TOKEN_MAP[filter.condition]}("${filter.value}")` + `${docKeyExpression(filter.key)}.${condition}(${valueLiteral})` ) } else if (filter.condition === CONDITIONS.EMPTY) { expression.push(isEmptyExpression(filter.key)) } else if (filter.condition === CONDITIONS.NOT_EMPTY) { expression.push(`!${isEmptyExpression(filter.key)}`) } else { - const value = - typeof filter.value == "string" ? `"${filter.value}"` : filter.value + const condition = tokenOrThrow("condition", filter.condition) + const value = JSON.stringify(filter.value) - expression.push( - `doc["${filter.key}"] ${TOKEN_MAP[filter.condition]} ${value}` - ) + expression.push(`${docKeyExpression(filter.key)} ${condition} ${value}`) } first = false } @@ -120,7 +133,7 @@ function parseFilterExpression(filters: ViewFilter[]) { * @param groupBy - field to group calculation results on, if any */ function parseEmitExpression(field: string, groupBy: string) { - return `emit(doc["${groupBy}"], doc["${field}"]);` + return `emit(${docKeyExpression(groupBy)}, ${docKeyExpression(field)});` } /** @@ -181,7 +194,7 @@ export default function ( const filterExpression = parsedFilters ? `&& (${parsedFilters})` : "" const emitExpression = parseEmitExpression(field, groupBy || "_id") - const tableExpression = `doc.tableId === "${tableId}"` + const tableExpression = `doc.tableId === ${JSON.stringify(tableId)}` const coreExpression = statFilter ? `(${tableExpression} && ${statFilter})` : tableExpression @@ -195,6 +208,7 @@ export default function ( filters, schema, calculation, + ...(groupByMulti ? { groupByMulti } : {}), }, map: `function (doc) { if (${coreExpression} ${filterExpression}) {
packages/server/src/api/routes/view.ts+30 −25 modified@@ -8,19 +8,27 @@ import { import { paramResource } from "../../middleware/resourceId" import { permissions } from "@budibase/backend-core" import { builderRoutes, publicRoutes } from "./endpointGroups" +import env from "../../environment" -publicRoutes - .get( - "/api/v2/views/:viewId", - recaptcha, - authorizedResource( - permissions.PermissionType.VIEW, - permissions.PermissionLevel.READ, - "viewId" - ), - viewController.v2.get - ) - .get( +publicRoutes.get( + "/api/v2/views/:viewId", + recaptcha, + authorizedResource( + permissions.PermissionType.VIEW, + permissions.PermissionLevel.READ, + "viewId" + ), + viewController.v2.get +) + +builderRoutes + .get("/api/v2/views", viewController.v2.fetch) + .post("/api/v2/views", viewController.v2.create) + .put(`/api/v2/views/:viewId`, viewController.v2.update) + .delete(`/api/v2/views/:viewId`, viewController.v2.remove) + +if (env.SELF_HOSTED) { + publicRoutes.get( "/api/views/:viewName", recaptcha, paramResource("viewName"), @@ -31,16 +39,13 @@ publicRoutes rowController.fetchLegacyView ) -builderRoutes - .get("/api/v2/views", viewController.v2.fetch) - .post("/api/v2/views", viewController.v2.create) - .put(`/api/v2/views/:viewId`, viewController.v2.update) - .delete(`/api/v2/views/:viewId`, viewController.v2.remove) - .get("/api/views/export", viewController.v1.exportView) - .get("/api/views", viewController.v1.fetch) - .delete( - "/api/views/:viewName", - paramResource("viewName"), - viewController.v1.destroy - ) - .post("/api/views", viewController.v1.save) + builderRoutes + .get("/api/views/export", viewController.v1.exportView) + .get("/api/views", viewController.v1.fetch) + .delete( + "/api/views/:viewName", + paramResource("viewName"), + viewController.v1.destroy + ) + .post("/api/views", viewController.v1.save) +}
packages/server/src/db/inMemoryView.ts+16 −4 modified@@ -3,6 +3,7 @@ import { Row, Document, DBView } from "@budibase/types" // bypass the main application db config // use in memory pouchdb directly import { db as dbCore, utils } from "@budibase/backend-core" +import viewBuilder from "../api/controllers/view/viewBuilder" const Pouch = dbCore.getPouch({ inMemory: true }) @@ -24,15 +25,26 @@ export async function runView( _rev: undefined, })) ) + if (!view.meta) { + throw new Error("Legacy view metadata is missing") + } + + // Rebuild map/reduce from metadata to avoid executing untrusted map strings. + const groupByMulti = + view.meta.groupByMulti ?? view.meta.schema?.group?.type === "array" + const rebuiltView = viewBuilder(view.meta as any, groupByMulti) 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)")) + eval( + "fn = " + + rebuiltView?.map?.replace("function (doc)", "function (doc, emit)") + ) const queryFns: any = { - meta: view.meta, + meta: rebuiltView.meta, map: fn, } - if (view.reduce) { - queryFns.reduce = view.reduce + if (rebuiltView.reduce) { + queryFns.reduce = rebuiltView.reduce } const response: { rows: Row[] } = await db.query(queryFns, { include_docs: !calculation,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-rvhr-26g4-p2r8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27702ghsaADVISORY
- github.com/Budibase/budibase/commit/348659810cf930dda5f669e782706594c547115dghsax_refsource_MISCWEB
- github.com/Budibase/budibase/pull/18087ghsax_refsource_MISCWEB
- github.com/Budibase/budibase/releases/tag/3.30.4ghsax_refsource_MISCWEB
- github.com/Budibase/budibase/security/advisories/GHSA-rvhr-26g4-p2r8ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.