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:
replaceAll("+", "-")— eliminates the CSS adjacent sibling combinatorreplaceAll("/", "_")— eliminates the CSS path separator ambiguityreplaceAll("=", "")— 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.
Prevention & Best Practices
1. Use CSS.escape() for Dynamic Selectors
When you must build CSS selectors from arbitrary strings, the browser-native CSS.escape() function is purpose-built for this:
// Safe alternative for dynamic querySelector usage
document.querySelector("#" + CSS.escape(dynamicValue));
CSS.escape() handles the full range of CSS special characters, not just base64 artifacts.
2. Validate Encoding Output, Not Just Input
The SaltGUI vulnerability is a reminder that encoding transforms can introduce new injection vectors. When you encode data for use in a new context (HTML, CSS, SQL, shell), verify that the output of the encoding is safe for that context — not just that the input was sanitized.
3. Apply the Principle of Least Surprise
The original code stripped = with a regex but left + and / untouched. A security review of encoding functions should ask: "What characters does this encoding produce, and are all of them safe in the target context?"
4. Prefer replaceAll() Over .replace() for Global Substitutions
The old code used .replace(patEqualSigns, "") which, even with the regex, only replaced the first match sequence. Using replaceAll() is both clearer in intent and more correct.
5. Reference Standards
- OWASP DOM-based XSS Prevention Cheat Sheet: Always encode data at the point of use, not just at the point of input
- CWE-79: Improper Neutralization of Input During Web Page Generation
- RFC 4648 §5: The formal definition of base64url encoding used in the fix
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 thegflag 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 insaltgui/static/scripts/output/Output.js:1094and related files, where the output ofgetIdFromMinionId()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()inUtils.jswas updated to apply RFC 4648 §5 base64url substitutions (+→-,/→_,=→removed) afterbtoa()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.