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.


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 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.


References

Frequently Asked Questions

What is DOM-based XSS via CSS selector injection?

It occurs when attacker-controlled data is concatenated into a CSS selector string (e.g., passed to querySelector) without sanitization, allowing special characters to break the selector syntax and trigger JavaScript execution.

How do you prevent CSS selector injection in JavaScript?

Always sanitize or encode values before using them in CSS selectors. For ID generation from arbitrary strings, use base64url encoding (replacing +, /, = with safe characters) or CSS.escape() for dynamic selector values.

What CWE is DOM-based XSS?

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), specifically the DOM-based subtype where the payload never touches the server.

Is btoa() encoding enough to prevent CSS selector injection?

No. Standard btoa() produces base64 output that includes +, /, and = characters, all of which are invalid or ambiguous in CSS selectors and can be exploited to break out of the selector context.

Can static analysis detect CSS selector injection?

Yes. Tools like Semgrep can flag patterns where unescaped variables are concatenated into querySelector strings. Orbis AppSec's multi-agent AI scanner detected exactly this pattern in SaltGUI's Output.js.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #933

Related Articles

critical

How Cross-Site Scripting happens in fast-xml-parser and how to fix it

CVE-2026-25896 is a critical Cross-Site Scripting vulnerability in fast-xml-parser caused by improper handling of DOCTYPE entity declarations, allowing attackers to inject malicious scripts through crafted XML input. The fix upgrades the library from vulnerable versions (4.5.3 and 5.2.3) to patched releases (4.5.7 and 5.10.1), closing the attack vector in production code. This matters because fast-xml-parser is widely used to process user-supplied XML in Node.js applications, making any XSS flaw

critical

How Reflected XSS happens in Astro and how to fix it

CVE-2026-50146 is a reflected cross-site scripting (XSS) vulnerability in Astro versions prior to 6.3.3, where unescaped slot names could be injected into rendered HTML. The fix upgrades Astro from 5.18.1 to 6.3.3 (along with related packages `@astrojs/starlight` and `starlight-blog`), closing a code path that allowed attacker-controlled input to reach the browser without sanitization. Any Astro-based site that renders dynamic slot names from untrusted sources was potentially exposed to session

high

How Unsafe eval() in JavaScript Happens in React Components and How to Fix It

A high-severity code injection vulnerability was discovered in `TurnPlanner.tsx`, where the `parseInputExpr` function used JavaScript's `Function` constructor — effectively `eval()` — to evaluate user-provided mathematical expressions. The regex guard in place only checked for the presence of arithmetic operators, not whether the input was safe to execute, leaving the door open for arbitrary JavaScript injection. A targeted whitelist fix was applied to reject any input containing characters outs

critical

How Unsanitized IPC Data Injection happens in Electron/HTML and how to fix it

A content injection vulnerability in `src/NankaiTrough.html` allowed attacker-controlled IPC message data to flow directly into DOM properties without type coercion or validation. The fix explicitly converts all `request.data` fields to strings using `String()` with fallback defaults before assigning them to `document.title` and `innerText` properties, eliminating the risk of prototype pollution and unexpected object-to-string coercion attacks.

critical

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

A stored Cross-Site Scripting (XSS) vulnerability in `hasheous/wwwroot/pages/dataobjectdetail.js` allowed attackers with Moderator or Admin privileges to inject malicious HTML into DataObject attribute fields, executing arbitrary JavaScript in every visitor's browser. The fix replaces unsafe `innerHTML` assignments with `textContent` for plain text and a sanitized markdown renderer for AI-generated descriptions, eliminating the injection vector entirely.

high

How Stored XSS via Unsanitized GitHub README HTML Happens in JavaScript and How to Fix It

A high-severity stored Cross-Site Scripting (XSS) vulnerability was discovered in `custom_components/hacs_vision/frontend/panel.js`, where the backend fetched GitHub's pre-rendered README HTML and the frontend injected it directly into the DOM without sanitization. An attacker who controls a GitHub repository could embed malicious JavaScript in their README that executes automatically when any HACS Vision user views that repository's details, potentially exfiltrating credentials or hijacking the