Fix a path traversal out of the music library

`year` was appended to the album directory name AFTER safe() had run, so it
was the one path component reaching the filesystem unchecked:

  POST /api/request {"artist":"E","title":"p","year":"../../../../tmp/pwned"}
  -> library/E/Singles (../../../../tmp/pwned)/p.flac
  -> normalises to ../tmp/pwned)/p.flac

Any authenticated user could do it, guests included, and it wrote a real
audio file to an arbitrary path the process could reach. Existing files were
not overwritten -- tag_and_file returns early when the destination exists --
so this created new files and directories rather than clobbering.

Fixed in two layers, because the first is a property of five call sites that
must each stay correct and the second cannot be forgotten:

  1. safe_year() whitelists \d{4}, applied in enqueue_job (the choke point for
     all three request paths) and again in tag_and_file.
  2. tag_and_file resolves the destination and refuses anything that is not
     under the library root, before mkdir rather than after.

Also from the same pass:

  - Cap import at 500 pasted lines / 1000 expanded tracks. URL expansion runs
    BEFORE the quota check -- it has to, since a playlist URL is one line and
    200 tracks -- so without a cap a paste of 10,000 album URLs was 10,000
    requests to Deezer from this IP before any quota was consulted.
  - Clamp search limit (1-50), jobs limit (1-1000) and activity days (1-365).
    search limit fanned out one /album/{id} enrichment per distinct album.
  - safe_cover() requires https and <=300 chars. img-src permits data:, so
    the field was free storage for megabytes of base64 per job.
  - Truncate filename components to 150 chars; a long title used to fail the
    import with ENAMETOOLONG after the download had already completed.
  - Treat Deezer's 403 as no-results. Its WAF rejects injection-looking
    queries, which surfaced to the user as a 502 "service broken".

Add tests/: 42 security assertions and 35 authorization assertions covering
the role matrix, last-admin protection, mid-session revocation, job scoping
and OAuth CSRF. All 77 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 01:45:41 -05:00
1 parent 6ef580780d
commit cc3490483e
3 files changed
+346 -13

No files matched your search

+80 -13
View File
@@ -40,7 +40,7 @@ from pathlib import Path
from urllib.parse import urlencode
import httpx
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
RedirectResponse)
from fastapi.staticfiles import StaticFiles
@@ -323,11 +323,36 @@ def require_admin(request: Request) -> str:
SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
NOISE = re.compile(r"[^a-z0-9]+")
YEAR = re.compile(r"^\d{4}$")
# Most filesystems cap a path component at 255 bytes. Track titles can be far
# longer than that once a stranger has appended their own notes, and the
# resulting ENAMETOOLONG would fail the import after the download had finished.
MAX_COMPONENT = 150
def safe(name: str) -> str:
"""Filesystem-safe path component. Trailing dots/spaces break on some FS."""
return SAFE.sub("_", name).strip(". ") or "Unknown"
return SAFE.sub("_", name).strip(". ")[:MAX_COMPONENT].strip() or "Unknown"
def safe_cover(url) -> str | None:
"""Artwork URL, stored and later rendered as an <img src>.
The CSP already stops a foreign host loading, so this is not an XSS gate —
it stops the field being used as free storage. `data:` is permitted by
img-src, so without a check any client could park megabytes of base64 in
the jobs table on every request.
"""
u = str(url or "").strip()
return u if u.startswith("https://") and len(u) <= 300 else None
def safe_year(year) -> str | None:
"""A year, or nothing. This lands in a directory name, so it is checked
against a whitelist rather than merely scrubbed."""
y = str(year or "").strip()
return y if YEAR.match(y) else None
def norm(s: str) -> str:
@@ -454,7 +479,7 @@ def slskd_client(timeout: float = 30.0) -> httpx.AsyncClient:
PROBE_TIMEOUT = 4.0
def why(e: Exception) -> str:
def errstr(e: Exception) -> str:
"""httpx's timeout and connection errors often str() to an empty string,
which would render as 'slskd unreachable — ' with nothing after it."""
return (str(e) or type(e).__name__)[:120]
@@ -598,14 +623,25 @@ def tag_and_file(job: sqlite3.Row, src: Path) -> Path:
log.warning("tagging failed for %s: %s", src.name, e)
album = f"{safe(job['album'] or 'Singles')}"
if job["year"]:
album += f" ({job['year']})"
year = safe_year(job["year"])
if year:
album += f" ({year})"
dest_dir = LIBRARY_DIR / safe(job["artist"]) / album
dest_dir.mkdir(parents=True, exist_ok=True)
num = f"{int(job['track']):02d} - " if job["track"] else ""
dest = dest_dir / f"{num}{safe(job['title'])}{src.suffix.lower()}"
# Belt and braces. Every component above is either safe()'d or matched
# against a whitelist, but that is a property of five call sites that must
# each stay correct; this is one check that cannot be forgotten. It exists
# because `year` was for a while appended AFTER safe() ran, so a request
# with year="../../../../tmp/x" wrote outside the library.
root = LIBRARY_DIR.resolve()
if not Path(os.path.normpath(dest)).resolve().is_relative_to(root):
raise ValueError(f"refusing to write outside the library: {dest}")
dest_dir.mkdir(parents=True, exist_ok=True)
if dest.exists():
return dest
try:
@@ -1136,6 +1172,13 @@ async def deezer_search(q: str, limit: int = 25) -> list[dict]:
return []
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(f"{DEEZER}/search", params={"q": q, "limit": limit * 2})
# Deezer's WAF answers 403 to anything that looks like an injection
# attempt -- "'; DROP TABLE" and friends. That is a statement about the
# query, not an upstream outage, so it belongs on the empty-results
# path; a 502 would tell the user the service is broken when it is not.
if r.status_code == 403:
log.info("deezer refused the query %r", q[:60])
return []
if r.status_code != 200:
raise HTTPException(502, f"deezer returned {r.status_code}")
data = r.json()
@@ -1185,7 +1228,7 @@ async def deezer_search(q: str, limit: int = 25) -> list[dict]:
@app.get("/api/search")
async def search(q: str, limit: int = 25):
async def search(q: str, limit: int = Query(25, ge=1, le=50)):
"""Track search. Returns individual songs, not albums — the whole point.
Year and genre are grafted on afterwards because Deezer's track search
@@ -1315,6 +1358,13 @@ async def enqueue_job(artist: str, title: str, album=None, year=None,
cover=None, genre=None, duration=None) -> str:
"""Create a job and hand it to the search queue."""
job_id = str(uuid.uuid4())
# The single choke point for all three request paths, so normalise here.
# year reaches a directory name, and artist/title/album reach filenames.
year = safe_year(year)
cover = safe_cover(cover)
artist, title = artist[:MAX_COMPONENT], title[:MAX_COMPONENT]
album = album[:MAX_COMPONENT] if album else album
genre = genre[:80] if genre else genre
with db() as c:
c.execute(
"INSERT INTO jobs (id,state,detail,artist,title,album,year,track,"
@@ -1397,6 +1447,11 @@ class ImportBody(BaseModel):
SPLIT = re.compile(r"\s+[-–—|]\s+|\s+by\s+", re.I)
DEEZER_URL = re.compile(r"deezer\.com/(?:[a-z]{2}/)?(album|playlist)/(\d+)")
# Generous for a human pasting an album or a year-end list, small enough that
# one request cannot turn into thousands of upstream calls.
MAX_IMPORT_LINES = 500
MAX_IMPORT_TRACKS = 1000
async def expand_url(kind: str, ident: str) -> list[str]:
"""Turn a Deezer album/playlist URL into 'Artist - Title' lines."""
@@ -1424,12 +1479,23 @@ async def import_list(body: ImportBody, request: Request):
than silently landing the wrong song in the library.
"""
lines, queued, skipped = [], [], []
for raw in body.text.splitlines():
# Capped before expansion, not after. Each URL line costs an upstream call,
# and the quota check below cannot run until expansion has finished — so
# without a cap here a paste of 10,000 album URLs is 10,000 requests to
# Deezer from this IP before anyone's quota is consulted.
raw_lines = body.text.splitlines()
if len(raw_lines) > MAX_IMPORT_LINES:
raise HTTPException(413, f"paste at most {MAX_IMPORT_LINES} lines at a time "
f"(got {len(raw_lines)})")
for raw in raw_lines:
line = raw.strip().lstrip("-•*0123456789. )").strip()
if not line:
continue
m = DEEZER_URL.search(line)
lines.extend(await expand_url(*m.groups()) if m else [line])
if len(lines) > MAX_IMPORT_TRACKS:
raise HTTPException(413, f"that expands to more than "
f"{MAX_IMPORT_TRACKS} tracks")
# After expansion — a single playlist URL can be 200 tracks, and the point
# of the check is the real number, not the number of lines pasted.
@@ -1445,7 +1511,7 @@ async def import_list(body: ImportBody, request: Request):
# version -- 6x the popularity -- sat further down the list unseen.
hits = await deezer_search(SPLIT.sub(" ", line), limit=10)
except Exception as e:
skipped.append({"line": line, "why": str(e)[:120]})
skipped.append({"line": line, "why": errstr(e)})
continue
if not hits:
skipped.append({"line": line, "why": "no match on Deezer"})
@@ -1465,7 +1531,8 @@ async def import_list(body: ImportBody, request: Request):
@app.get("/api/jobs")
async def jobs(request: Request, limit: int = 200, mine: bool = False):
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
mine: bool = False):
"""Own requests by default; admins see everyone's.
Scoped rather than global because on a shared instance one person's
@@ -1646,7 +1713,7 @@ async def admin_delete_user(username: str, request: Request):
@app.get("/api/admin/activity")
async def admin_activity(request: Request, days: int = 30):
async def admin_activity(request: Request, days: int = Query(30, ge=1, le=365)):
require_admin(request)
days = max(1, min(days, 365))
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
@@ -1785,7 +1852,7 @@ async def admin_system(request: Request):
else:
slskd["error"] = f"HTTP {r.status_code}"
except Exception as e:
slskd["error"] = why(e)
slskd["error"] = errstr(e)
out["slskd"] = slskd
return out
@@ -1799,7 +1866,7 @@ async def health():
detail["slskd"] = r.status_code
ok = ok and r.status_code == 200
except Exception as e:
detail["slskd"] = why(e)
detail["slskd"] = errstr(e)
ok = False
detail["library_writable"] = os.access(LIBRARY_DIR, os.W_OK)
detail["downloads_readable"] = os.access(DOWNLOAD_DIR, os.R_OK)