`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>