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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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

fast-xml-parser 4.5.0 XSS: DOCTYPE Entity Injection in XML.parse()

fast-xml-parser versions 4.5.0 and earlier fail to sanitize DOCTYPE entity declarations during XML parsing, allowing attackers to inject JavaScript through crafted XML payloads. The vulnerability permits reflected XSS in applications that process untrusted XML input. Upgrading to version 5.7.0 eliminates the unsafe entity expansion path.

high

sanitizeBangumiHtml() XSS Fix: insertAdjacentHTML RCE via JSON

A high-severity cross-site scripting vulnerability existed where untrusted JSON data from bangumis.json was rendered directly into the DOM using insertAdjacentHTML without sanitization. An attacker who could modify this external data source could execute arbitrary JavaScript in visitors' browsers. The fix introduces a dedicated sanitizeBangumiHtml() function that strips script tags, event handlers, and javascript: URLs before insertion.

high

form.js jQuery Selector Construction: CWE-79 Quote-Breaking Fix

A defense-in-depth fix in the form.js initialization routine eliminates a jQuery selector injection vector where user-controlled category data was concatenated directly into a string literal. The vulnerable pattern at lines 47-48 of the form handler could allow malicious content to break out of the selector's quoted context and execute arbitrary jQuery methods.

high

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

high

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo

high

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.