From 9648a79f3fdf01bf8547252ac7b0d97d84c378ce Mon Sep 17 00:00:00 2001 From: rays Date: Sun, 11 Oct 2026 13:23:27 +0000 Subject: [PATCH] Keep media out of shared caches (#189) Played from ipodderx.sdf1.net, DeadAir's audio reached ipx as one request with no Range header: Cloudflare caches by extension, deadair.mp3 has one, and /media said nothing about caching, so Cloudflare fetched the whole file and served ranges from its own copy, to anyone Access let in, without the subscriber check (#129). /media/{id} has no extension and was never cached. Cache-Control: private on every media response, the save included. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/web.rs | 8 +++++++- tests/ui/app.spec.js | 8 ++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c286a1b..4418408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177). - DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space (#181). +### Security + +- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189). + ## [0.11.0] - 2026-10-09 ### Added diff --git a/src/web.rs b/src/web.rs index bb4d0c7..676b7d4 100644 --- a/src/web.rs +++ b/src/web.rs @@ -2012,6 +2012,7 @@ async fn media_save(State(state): State, Path(id): Path, user: cr (header::CONTENT_TYPE, kind.to_owned()), (header::CONTENT_LENGTH, len.to_string()), (header::CONTENT_DISPOSITION, format!("attachment; filename*=UTF-8''{name}")), + (header::CACHE_CONTROL, "private".to_owned()), ], axum::body::Body::from_stream(body), ) @@ -2044,7 +2045,12 @@ async fn serve_media(state: WebState, id: i64, user: crate::db::User, req: Reque state.playing.lock().unwrap().remove(&(user.id, enc.feed_id.clone(), enc.guid.clone())); } match ServeFile::new(crate::download::playable(std::path::Path::new(&path))).oneshot(req).await { - Ok(r) => r.into_response(), + Ok(mut r) => { + // Never in a shared cache: Cloudflare kept `deadair.mp3`, by its extension, and served + // its copy to anyone Access let in, without the subscriber check above (#189). + r.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private")); + r.into_response() + } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 9cb905a..a335a55 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -1791,12 +1791,16 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y // Saved without its dead air too (#186): smaller than the original, named to say so. const saved = await page.evaluate(async id => { - const [d, o] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`)]); - return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength }; + const [d, o, p] = await Promise.all([fetch(`/media/${id}/deadair`), fetch(`/media/${id}`), fetch(`/media/${id}/deadair.mp3`, { headers: { Range: 'bytes=0-99' } })]); + return { status: d.status, name: d.headers.get('content-disposition'), size: (await d.arrayBuffer()).byteLength, full: (await o.arrayBuffer()).byteLength, + // Kept out of shared caches, or Cloudflare serves its own copy to anyone (#189). + caching: [d, o, p].map(r => r.headers.get('cache-control')), piece: p.status }; }, (await entry()).enclosures[0].id); expect(saved.status).toBe(200); expect(saved.name).toContain('DeadAir'); expect(saved.size).toBeLessThan(saved.full); + expect(saved.caching).toEqual(['private', 'private', 'private']); + expect(saved.piece).toBe(206); // Played through DeadAir's playlist (#181), by hls.js in Chromium, with the switch beside the // speed pressed. The element's own src is then hls.js's blob: address.