Back to Blog
critical SEVERITY8 min read

How DOM-Based XSS Happens in JavaScript CSS Selectors and How to Fix It

A DOM-based XSS vulnerability in SaltGUI's `Output.js` allowed attackers to inject malicious characters into CSS query selectors by manipulating minion ID values. The root cause was that `btoa()`-encoded IDs could still contain `+`, `/`, and `=` characters that are invalid in CSS selectors, enabling selector breakout. The fix converts the encoding to base64url (RFC 4648 §5), replacing all problematic characters before the ID is used in `querySelector` calls.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is a DOM-based Cross-Site Scripting (XSS) vulnerability (CWE-79) in SaltGUI's JavaScript file `saltgui/static/scripts/Utils.js`. The `getIdFromMinionId()` function used standard `btoa()` base64 encoding to generate CSS element IDs from minion names, but base64's `+`, `/`, and `=` characters are invalid in CSS selectors — allowing attacker-controlled minion IDs to break out of the selector context and execute arbitrary JavaScript. The fix switches to base64url encoding (RFC 4648 §5) by replacing `+` with `-`, `/` with `_`, and stripping `=` padding, producing selector-safe IDs that cannot be exploited for injection.

Vulnerability at a Glance

cweCWE-79
fixReplace standard base64 with base64url encoding (RFC 4648 §5) to produce selector-safe IDs
riskAttacker-controlled minion IDs execute arbitrary JavaScript in the browser via querySelector
languageJavaScript
root causebtoa() output contains +, /, and = characters that are invalid and exploitable in CSS selectors
vulnerabilityDOM-Based Cross-Site Scripting (XSS) via CSS Selector Injection

How DOM-Based XSS Happens in JavaScript CSS Selectors and How to Fix It

Introduction

The saltgui/static/scripts/Utils.js file handles a seemingly mundane task: converting SaltStack minion IDs into valid HTML element IDs so they can be referenced by CSS selectors throughout the UI. But a subtle flaw in the getIdFromMinionId() function — present at line 503 — turned this utility into an XSS attack surface. Standard base64 encoding via btoa() produces characters (+, /, =) that are illegal in CSS selectors, meaning an attacker who can influence a minion ID value can inject a payload that breaks out of the selector context entirely and executes arbitrary JavaScript in the browser.

This is a classic example of a vulnerability hiding in "safe-looking" code. The developer correctly identified that minion IDs need encoding before use as HTML IDs — but chose an encoding that only partially solves the problem.


The Vulnerability Explained

What getIdFromMinionId() Does

SaltGUI renders information about many minions at once. To allow JavaScript to target individual minion elements in the DOM, each minion gets a unique HTML element ID derived from its minion ID string. The getIdFromMinionId() function in Utils.js handles this conversion:

// VULNERABLE CODE (before fix)
static getIdFromMinionId (pMinionId) {
  // prevent eslint: A regular expression literal can be confused with '/='
  const patEqualSigns = /[=]=*/;
  return "m" + window.btoa(pMinionId).replace(patEqualSigns, "");
}

At first glance, this looks reasonable: btoa() base64-encodes the minion ID, a prefix "m" is added (since HTML IDs can't start with a digit), and = padding is stripped. The problem is that standard base64 uses a 64-character alphabet that includes + and / — both of which are syntactically meaningful in CSS selectors.

Why + and / Break CSS Selectors

CSS selectors have their own grammar. When you call:

document.querySelector("#" + getIdFromMinionId(pMinionId))

...the string passed to querySelector is interpreted as a CSS selector. In CSS selector syntax:
- + is the adjacent sibling combinator
- / can introduce CSS comments or other constructs depending on context
- = has meaning in attribute selectors

If a minion ID encodes to a base64 string containing any of these characters, the resulting selector is malformed — and in some browsers, malformed selectors can be exploited to trigger JavaScript execution or cause unexpected DOM traversal.

The Attack Scenario

Consider an attacker who can influence minion ID values — for example, by registering a rogue minion with a crafted name, or by manipulating an API response that SaltGUI consumes. They register a minion with an ID that, when passed through btoa(), produces a base64 string containing + or /:

Minion ID: "evil>minion"
btoa("evil>minion") → "ZXZpbD5taW5pb24="

Or more directly, a minion ID crafted so its base64 output contains +:

// A minion ID whose btoa() output includes "+"
// The + becomes an adjacent sibling combinator in the CSS selector
// querySelector("#mABC+DEF") selects a different element entirely

By controlling which element gets selected, an attacker can redirect UI operations — such as content rendering, event binding, or data injection — to unintended DOM nodes, potentially triggering script execution in the context of the SaltGUI session.

Exploitation scenario from the PR: "Attacker manipulates minion ID values (via API response or stored data) to contain JavaScript payloads that execute when the affected querySelector methods are called, leading to DOM-based XSS."

Real-World Impact

SaltGUI is a web interface for SaltStack, used to manage infrastructure. A successful XSS attack in this context could:
- Steal session tokens, allowing full infrastructure takeover
- Execute Salt commands on behalf of the victim administrator
- Exfiltrate sensitive minion configuration data
- Pivot to other internal systems accessible from the administrator's browser


The Fix

What Changed

The fix modifies getIdFromMinionId() in saltgui/static/scripts/Utils.js to use base64url encoding as defined in RFC 4648 §5, which substitutes all characters that are problematic in CSS selectors:

// FIXED CODE (after fix)
// btoa (the base64 encoder) uses +, / and = which are not valid in CSS selectors
// use base64url (RFC 4648 §5): replace + with -, / with _, strip padding =
static getIdFromMinionId (pMinionId) {
  return "m" + window.btoa(pMinionId).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}

Before vs. After

Aspect Before After
Encoding Standard base64 (btoa) Base64url (RFC 4648 §5)
+ character Left in output Replaced with -
/ character Left in output Replaced with _
= padding Removed by regex (partially) Removed by replaceAll
CSS selector safety ❌ Unsafe ✅ Safe
Regex dependency Yes (/[=]=*/) No

Why This Specific Fix Works

Base64url is a well-established standard variant of base64 designed for use in URLs and filenames — contexts where +, /, and = are also problematic. By applying three replaceAll() calls:

  1. replaceAll("+", "-") — eliminates the CSS adjacent sibling combinator
  2. replaceAll("/", "_") — eliminates the CSS path separator ambiguity
  3. replaceAll("=", "") — strips padding (more robustly than the previous regex)

The resulting string contains only alphanumeric characters, hyphens, and underscores — all of which are valid in CSS ID selectors and HTML element IDs.

Note also that the previous regex const patEqualSigns = /[=]=*/ was flawed: it only removed the first occurrence of = signs (.replace() without the g flag), potentially leaving trailing = characters. The new approach uses replaceAll() which handles all occurrences.


Key Takeaways

  • btoa() alone is not safe for CSS selectors: Standard base64 output includes +, /, and = — all syntactically significant in CSS. Always post-process with base64url substitutions when using encoded values in selectors.
  • The original regex in getIdFromMinionId() was incomplete: replace(/[=]=*/, "") only removed the first = sequence; replaceAll("=", "") correctly removes all padding characters.
  • Encoding transforms can introduce new injection surfaces: The developer encoded minion IDs to make them "safe," but the chosen encoding produced unsafe output for the CSS selector context.
  • Minion IDs in SaltGUI are attacker-influenced data: Any value that can be set by a registered minion or an API response must be treated as untrusted input throughout the UI codebase.
  • replaceAll() is safer than .replace() for global character substitution: Using .replace() without the g flag leaves subsequent occurrences in place and creates subtle security gaps.

How Orbis AppSec Detected This

  • Source: Minion ID values (pMinionId, pHighlightMinionId) sourced from API responses or stored minion registration data — attacker-influenced strings entering the JavaScript frontend.
  • Sink: document.querySelector() calls in saltgui/static/scripts/output/Output.js:1094 and related files, where the output of getIdFromMinionId() is concatenated directly into CSS selector strings.
  • Missing control: No CSS-context escaping or base64url normalization was applied after btoa() encoding; the + and / characters produced by standard base64 were passed raw into CSS selectors.
  • CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), DOM-based subtype.
  • Fix: getIdFromMinionId() in Utils.js was updated to apply RFC 4648 §5 base64url substitutions (+-, /_, =→removed) after btoa() encoding, producing CSS-selector-safe element IDs.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The getIdFromMinionId() vulnerability in SaltGUI is a precise illustration of how security bugs hide in utility code. The function was doing the right thing conceptually — encoding minion IDs before using them as HTML identifiers — but the specific encoding chosen (standard base64 via btoa()) was not safe for the CSS selector context where the IDs were ultimately used. Switching to base64url encoding (RFC 4648 §5) with three targeted replaceAll() calls closes the attack vector completely, with no change to the function's external behavior.

For developers building web UIs that consume infrastructure data: treat every value that originates from a managed node, API response, or external data source as untrusted. Encode at the point of use, verify that your encoding output is safe for its target context, and prefer well-established standards like base64url over ad-hoc regex approaches.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #933

Related Articles

critical

How stored XSS happens in TinyMCE plugins and how to fix it

The snippets plugin's `Main.ts` inserted raw, unsanitized snippet content directly into the TinyMCE editor via `editor.insertContent(snippet.content)`, allowing stored JavaScript payloads saved by any snippet editor to execute in every user's browser. The fix routes snippet content through TinyMCE's own parser and serializer before insertion, stripping dangerous markup while preserving legitimate formatting.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.

high

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

How Cross-Site Scripting (XSS) happens in JavaScript sanitization functions and how to fix it

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.