How Cross-Site Scripting (XSS) Happens in JavaScript Template Rendering and How to Fix It
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Cross-Site Scripting (XSS) |
| CWE | CWE-79 |
| Language | JavaScript |
| Risk | Attacker-controlled mod metadata executes arbitrary JavaScript in the application renderer |
| Root Cause | catIcon(m.categoryId) interpolated into innerHTML without HTML encoding |
| Fix | Wrap catIcon(m.categoryId) with the existing esc() helper |
Direct Answer
This is a Cross-Site Scripting (XSS) vulnerability (CWE-79) in renderer/views/library.js, specifically in the packThumbGridHtml() function at line 56. The return value of catIcon(m.categoryId) was interpolated directly into an innerHTML template string without HTML encoding, while other dynamic values in the same template (like p, the preview URL) were already protected by the esc() helper. The fix wraps catIcon(m.categoryId) with esc(), ensuring all dynamic content in that template literal is HTML-encoded before DOM insertion.
Introduction
The renderer/views/library.js file is responsible for rendering the visual library interface of a Tauri-based desktop application—displaying mod packs, their thumbnails, and associated metadata. Most of the dynamic values flowing into its innerHTML assignments were already being sanitized through an esc() helper function. But one value slipped through: the return value of catIcon(m.categoryId) inside packThumbGridHtml().
This inconsistency—a single unescaped interpolation surrounded by properly escaped ones—is exactly the kind of subtle mistake that creates real XSS exposure. The surrounding code looked safe, which makes this easy to miss in code review.
The Vulnerability Explained
What Was Happening at Line 56
Inside packThumbGridHtml(), the code builds HTML for a pack thumbnail grid by iterating over member records. For members without a preview image, it renders a fallback cell using a category icon:
// BEFORE (vulnerable)
if (!p) return `<div class="pack-thumb-cell"><span class="ms">${catIcon(m.categoryId)}</span></div>`;
Compare this to the lines immediately below it, where preview URLs are properly escaped:
return isVideo(p)
? `<video src="${esc(p)}" muted playsinline preload="metadata"></video>`
: `<img src="${esc(p)}" loading="lazy" alt="">`;
The esc() function is called for p in both the <video> and <img> cases. But catIcon(m.categoryId) is inserted raw into the <span> content—no esc() call, no encoding.
Why This Is Exploitable
The categoryId field comes from mod metadata—data that can originate from malicious preset files or mod packs loaded by the application. If an attacker crafts a mod pack where a member's categoryId maps to a category icon string containing HTML, that string lands unescaped in the DOM.
For example, if catIcon() returns a value derived from or influenced by m.categoryId, and an attacker supplies:
"><img src=x onerror=alert(1)>
...the resulting HTML becomes:
<div class="pack-thumb-cell">
<span class="ms">"><img src=x onerror=alert(1)></span>
</div>
The injected onerror handler executes JavaScript in the renderer process. In a Tauri application, the renderer runs in an Electron-like WebView context—script execution here can have access to Tauri's IPC bridge, potentially escalating to system-level operations depending on the application's permission model.
The Broader Pattern Risk
The PR description notes that library.js uses innerHTML with string concatenation in multiple locations for rendering mod lists, pack rows, and member rows. The esc() function only escapes &, <, >, and ". While this covers standard HTML injection in element content and quoted attribute values, the pattern itself is inherently fragile: every new template literal added to the file is a potential XSS if a developer forgets to wrap a value in esc().
The Fix
What Changed
The fix is surgical and minimal—a single esc() call wrapping the previously unescaped catIcon() output:
// BEFORE (vulnerable) — renderer/views/library.js:56
if (!p) return `<div class="pack-thumb-cell"><span class="ms">${catIcon(m.categoryId)}</span></div>`;
// AFTER (fixed)
if (!p) return `<div class="pack-thumb-cell"><span class="ms">${esc(catIcon(m.categoryId))}</span></div>`;
Why This Works
The esc() function converts HTML-special characters into their entity equivalents before they reach the DOM parser. So if catIcon(m.categoryId) returns anything containing <, >, ", or &, those characters are neutralized:
| Input character | Encoded output |
|---|---|
< |
< |
> |
> |
" |
" |
& |
& |
The browser renders these as literal characters in the text content of the <span>, rather than interpreting them as HTML markup. The injected onerror handler never gets parsed.
Behavior Preservation
For valid category icon values—which are expected to be simple strings like "extension" or "auto_awesome" (Material Symbols icon names)—esc() is a no-op. No visual change occurs for legitimate data. Only malicious or malformed input is affected.
Prevention & Best Practices
1. Apply esc() Consistently to Every Dynamic Value in innerHTML
The root cause here was inconsistency: some values were escaped, one was not. A code review checklist item for this codebase should be: "Does every ${} interpolation inside an innerHTML template literal pass through esc()?"
Consider a linting rule or ESLint plugin (e.g., eslint-plugin-no-unsanitized) that flags raw interpolations into innerHTML.
2. Prefer DOM APIs Over innerHTML for Dynamic Content
Where feasible, replace innerHTML template construction with explicit DOM API calls:
// Safer alternative
const span = document.createElement('span');
span.className = 'ms';
span.textContent = catIcon(m.categoryId); // textContent never parses HTML
const div = document.createElement('div');
div.className = 'pack-thumb-cell';
div.appendChild(span);
return div.outerHTML;
textContent treats its value as a text node—no HTML parsing occurs, so no encoding is needed.
3. Consider a Sanitization Library for Complex HTML
If the application needs to render richer HTML from external sources, use DOMPurify rather than a hand-rolled esc() function. DOMPurify handles a broader range of XSS vectors including SVG-based injection, namespace confusion attacks, and more.
4. Audit All innerHTML Assignments in the File
The PR description notes that library.js uses this pattern in multiple locations. A full audit should verify that every innerHTML assignment in the file—for mod lists, pack rows, and member rows—applies esc() to every interpolated value.
5. Treat Mod Metadata as Untrusted Input
Even locally-loaded files (preset files, mod packs) should be treated as potentially attacker-controlled. Users may share packs, download them from third-party sources, or be socially engineered into loading malicious files. The threat model should assume any field in a mod record can contain adversarial content.
OWASP & Standards Alignment
- OWASP XSS Prevention Cheat Sheet: Recommends output encoding as the primary defense, with the encoding function matched to the output context.
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
Key Takeaways
catIcon(m.categoryId)was the only unescaped interpolation in a template literal where every other dynamic value was wrapped inesc()—a subtle inconsistency that created real XSS exposure.- Mod metadata is attacker-controlled input. Fields like
categoryIdoriginate from files that users load from external sources; they must be treated with the same skepticism as HTTP request parameters. - The
esc()helper was already present and working—this vulnerability existed not because the tool was missing, but because it wasn't applied uniformly. Consistency in applying sanitization is as important as having the sanitization function. - In Tauri renderer contexts, XSS has elevated risk. Script execution in the WebView may have access to Tauri IPC, making renderer-level XSS a potential stepping stone to system-level impact.
- A one-line fix closed the vector: wrapping
catIcon(m.categoryId)withesc()inpackThumbGridHtml()is all that was needed, with zero impact on valid inputs.
How Orbis AppSec Detected This
- Source: The
m.categoryIdfield from a member record in a mod pack—data that originates from externally loaded preset or pack files and is passed tocatIcon(). - Sink: The unencoded
${catIcon(m.categoryId)}interpolation inside aninnerHTMLtemplate literal inpackThumbGridHtml()atrenderer/views/library.js:56. - Missing control: No call to
esc()(or any other HTML encoding function) around thecatIcon()return value before DOM insertion, despiteesc()being applied to all other dynamic values in the same template. - CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
- Fix: Wrapped
catIcon(m.categoryId)with the existingesc()helper to HTML-encode the value before it is inserted into the DOM viainnerHTML.
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 vulnerability is a textbook example of how XSS hides in plain sight: the packThumbGridHtml() function in library.js was mostly doing the right thing. Preview URLs were escaped. Video sources were escaped. But one value—the category icon string returned by catIcon(m.categoryId)—was not, and that single omission was enough to open an injection path for anyone who could influence mod metadata.
The fix is a one-line change that brings catIcon(m.categoryId) in line with the encoding discipline already applied to the surrounding code. But the broader lesson is about consistency: security controls are only as strong as their most inconsistent application. If your codebase uses innerHTML with template literals, every single interpolated value needs to go through your encoding function—no exceptions, no assumptions about what "safe" values look like.
Automated tools that trace data flow from sources (external files, user input) to sinks (innerHTML, eval, document.write) are the most reliable way to catch these inconsistencies before they reach production.
References
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- OWASP Cross Site Scripting Prevention Cheat Sheet
- OWASP DOM-based XSS Prevention Cheat Sheet
- MDN: Element.innerHTML — Security considerations
- Semgrep rules for innerHTML XSS
- eslint-plugin-no-unsanitized
- fix: add output encoding in library.js