fix(media): close the percent-escape bypass of the media gate

`/media/%70reviews/{id}.jpg` served a taken-down photo to anyone,
unauthenticated. Verified against the running stack: the literal path 404s,
the escaped one returned 200 with the full image. Same for displays,
thumbnails and originals, and any escaped byte in any position works.

Cause: the block was four `nest_service("/media/previews", 404)` route
matches sitting above a `ServeDir` on `/media`. axum matches on the RAW path
(matchit does no percent-decoding), while `ServeDir` percent-decodes when it
resolves the file. So `%70reviews` missed every blocker, fell through to the
ServeDir, and was decoded back to `previews/` on disk — reaching the bytes
with no soft-delete and no ban-hide check. That defeats a host takedown,
which is the entire point of the gate.

Remove the `/media` route tree outright instead of racing the decoder.
Nothing needs it: every media URL the backend emits is already a gated
`/api/v1/upload/{id}/{original,preview,display,thumbnail}` alias
(handlers::feed), the frontend contains zero `/media/` references, and the
`/media` in config.rs/disk.rs is the filesystem path while `media/` in
export.rs is a path inside the zip. `/media/**` now 404s regardless of
encoding. The route's own comment already said it "serves nothing" — it
wasn't a backstop, it was the vector.

Caddy keeps proxying /media/* deliberately: the app 404s it, and forwarding
means the e2e gating specs exercise the app's refusal exactly as production
would rather than being masked by the SvelteKit 404 page.

Extend the gating spec with the encoded variants — asserting only the literal
spelling is what let this sit undetected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-27 21:21:18 +02:00
parent cec69e804a
commit 42416d76e2
3 changed files with 44 additions and 33 deletions

View File

@@ -45,6 +45,23 @@ test.describe('Media gating — moderation revokes preview access (F2)', () => {
const direct = await fetch(`${BASE}/media/previews/${id}.jpg`);
expect(direct.status, 'direct /media/previews must be blocked').toBe(404);
// …and it must stay blocked under percent-encoding. The block used to be four
// `nest_service("/media/previews", 404)` route matches sitting above a `/media`
// ServeDir. axum routes on the RAW path while ServeDir percent-decodes afterwards, so
// ONE escaped byte (`%70` = `p`) missed every blocker, fell through to the ServeDir,
// and was decoded back to `previews/` on disk — serving the bytes unauthenticated.
// Asserting only the literal spelling is what let that sit here undetected.
for (const variant of [
`/media/%70reviews/${id}.jpg`, // p
`/media/p%72eviews/${id}.jpg`, // r — any position works
`/media/%64isplays/${id}.jpg`, // d
`/media/%74humbnails/${id}.jpg`, // t
`/media/%6Friginals/${id}.jpg`, // o
]) {
const res = await fetch(`${BASE}${variant}`, { redirect: 'manual' });
expect(res.status, `${variant} must not bypass the media block`).toBe(404);
}
// Host deletes the upload → the preview must stop being served.
const del = await fetch(`${BASE}/api/v1/host/upload/${id}`, {
method: 'DELETE',