VYPR
Critical severityNVD Advisory· Published Feb 25, 2026· Updated Feb 25, 2026

Budibase Vulnerable to Remote Code Execution via Unsafe eval() in View Filter Map Function (Budibase Cloud)

CVE-2026-27702

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.

PackageAffected versionsPatched versions
budibasenpm
< 3.30.43.30.4

Affected products

1

Patches

1
348659810cf9

Merge pull request #18087 from Budibase/fix/views-security-issue

https://github.com/Budibase/budibasePeter ClementFeb 21, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.