Introduction
A web-based MIDI/ABC editor builds a small checkbox UI for every instrument or program found in a loaded file. The function responsible, getCheckboxString(name, label), took those two values and dropped them straight into an HTML template literal — no escaping, no validation. The resulting string was then handed to DOMParser and inserted into the live page with replaceChildren().
That combination matters because name and label aren't operator-controlled configuration values — they come from parsing the instrument and program fields of an untrusted MIDI file a user opens in the editor. Any code path that turns "data extracted from a file format" into "raw HTML inserted into the DOM" without an escaping step is a textbook HTML/script injection primitive, even if no public exploit chain has been demonstrated yet.
Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) |
| Ecosystem | npm |
| CVE / GHSA | not assigned |
| CWE | unknown |
This is application code rather than a published dependency, so there's no version range to check — if you're running a build that still contains the unescaped getCheckboxString() implementation, you're exposed.
The Vulnerability Explained
Before the fix, the function looked like this:
function getCheckboxString(name, label) {
return `
<div class="form-check form-check-inline">
<label class="form-check-label">
<input class="form-check-input" name="${name}" value="${label}" type="checkbox" checked>
${label}
</label>
</div>`;
}
Both name and label are interpolated twice into the markup: once inside an HTML attribute (name="${name}", value="${label}") and once as element content (${label}). Neither position has any encoding applied, and the caller feeds this template with values taken directly from a parsed MIDI file's instrument and program metadata.
The resulting string is then parsed with DOMParser and attached to the page via replaceChildren(). Developers sometimes assume that parsing through DOMParser before insertion is "safer" than innerHTML — it isn't, when the string itself already contains executable markup like an <img onerror=...> tag or a closing "> that breaks out of the attribute context.
Attack scenario: An attacker crafts a MIDI file where an instrument or program name field contains something like "><img src=x onerror=alert(document.cookie)>. When a victim opens that file in the editor, getCheckboxString() builds the checkbox markup with that payload embedded as literal HTML, DOMParser turns it into real DOM nodes, and replaceChildren() mounts it in the page — firing the onerror handler in the victim's browser session. For any site that lets users share or preview MIDI files, this turns a file-upload feature into a script-execution vector.
The Fix
The patch introduces a dedicated escaping helper and routes both parameters through it before the template string is built:
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => ({
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
})[c]);
}
And getCheckboxString() now uses it on both inputs:
function getCheckboxString(name, label) {
const safeName = escapeHtml(name);
const safeLabel = escapeHtml(label);
return `...
<input class="form-check-input" name="${safeName}" value="${safeLabel}" type="checkbox" checked>
${safeLabel}
...`;
}
escapeHtml() converts the five characters that matter for breaking out of an HTML attribute or element context — &, <, >, ", ' — into their entity equivalents. That means a crafted instrument name like "><img src=x onerror=...> is rendered as inert text ("><img src=x onerror=...>) instead of being parsed as a new element. Both call sites that previously used the raw name and label were updated to use safeName and safeLabel, so the fix covers the attribute context and the element-content context in one pass, rather than patching just one of the two injection points.
Key Takeaways
- Treat MIDI instrument and program name fields as untrusted input — they're attacker-controlled strings pulled from an uploaded file, not internal configuration.
- Routing a string through
DOMParserbefore inserting it withreplaceChildren()does not sanitize it; a craftedonerrorattribute still fires once the parsed nodes are attached. - Centralizing escaping in one
escapeHtml()function and applying it at the single construction point (getCheckboxString()) is easier to audit than scattering ad hoc encoding across callers. - When a template literal interpolates the same value into both an attribute and element content, each context needs the escaped value — fixing only one leaves the other exploitable.
How Orbis AppSec Detected This
- Source: instrument and program name fields extracted while parsing an uploaded MIDI file.
- Sink: the HTML template literal inside
getCheckboxString(), parsed withDOMParserand attached viaElement.replaceChildren(). - Missing control: no output encoding of the
nameandlabelparameters before they were embedded in HTML attribute and element-content positions. - CWE: unknown (not formally assigned in this finding).
- Fix: added an
escapeHtml()helper and applied it to bothnameandlabelbefore building the checkbox 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
This finding is a reminder that file-format metadata is just as untrusted as a URL parameter or a form field — a MIDI instrument name is still attacker-controlled text once it reaches your rendering code. getCheckboxString() treated that text as safe markup for two separate HTML contexts, and DOMParser plus replaceChildren() didn't provide the sanitization developers might assume. Adding a single escapeHtml() helper and applying it consistently closes both injection points with a minimal, auditable change.