fix: don't mark private-mode blobs as publicly cacheable

The immutable Cache-Control added for the reader preload work was
`public` unconditionally. Under PRIVATE_MODE, /files is auth-gated, so a
shared cache/CDN would store the blob and serve it to unauthenticated
clients, defeating the gate. Emit `private, ...` when private_mode is on,
`public, ...` otherwise.

Found in the security audit; regression from 0.124.3. Bump to 0.124.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-07 21:39:20 +02:00
parent 795eb76f9a
commit 61669aac3f
5 changed files with 64 additions and 4 deletions

2
backend/Cargo.lock generated
View File

@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.124.8"
version = "0.124.9"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.124.8"
version = "0.124.9"
edition = "2021"
default-run = "mangalord"

View File

@@ -55,9 +55,19 @@ async fn serve(State(state): State<AppState>, Path(key): Path<String>) -> AppRes
// revalidation entirely — this is what lets the reader's page and
// next-chapter preloading hit cache instead of re-downloading (and
// re-proxying every byte through the SvelteKit node server in prod).
//
// BUT under PRIVATE_MODE these blobs are auth-gated (see
// `private_mode_guard`), so they must NOT be marked `public`: a shared
// cache / CDN in front of the app would store the object and then serve
// it to unauthenticated clients, defeating the gate. Use `private` so
// only the requesting user's browser caches it.
(
header::CACHE_CONTROL,
"public, max-age=31536000, immutable".to_string(),
if state.auth.private_mode {
"private, max-age=31536000, immutable".to_string()
} else {
"public, max-age=31536000, immutable".to_string()
},
),
];
Ok((headers, Body::from_stream(file.stream)).into_response())

View File

@@ -149,6 +149,56 @@ async fn private_mode_blocks_register_even_when_self_register_enabled(pool: PgPo
assert_eq!(body["error"]["code"], "forbidden");
}
#[sqlx::test(migrations = "./migrations")]
async fn private_mode_serves_files_as_private_not_public_cacheable(pool: PgPool) {
// In private mode /files is auth-gated, so a served blob must NOT carry a
// `public` Cache-Control: a shared cache / CDN would otherwise store it and
// hand it to anonymous clients, defeating the gate. It should be `private`.
//
// Register via a public harness on the shared pool so the session exists,
// then upload + fetch a cover through the private harness (same storage).
let public = common::harness(pool.clone());
let (_, cookie) = common::register_user(&public.app).await;
let private = common::harness_with_private_mode(pool);
let form = common::MultipartBuilder::new()
.add_json("metadata", json!({ "title": "Secret Library" }))
.add_file("cover", "cover.png", "image/png", &common::fake_png_bytes());
let created = private
.app
.clone()
.oneshot(common::post_multipart_with_cookie(
"/api/v1/mangas",
form,
&cookie,
))
.await
.unwrap();
assert_eq!(created.status(), StatusCode::CREATED);
let body = common::body_json(created).await;
let key = body["cover_image_path"].as_str().unwrap().to_string();
let resp = private
.app
.oneshot(common::get_with_cookie(
&format!("/api/v1/files/{key}"),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let cc = resp
.headers()
.get(axum::http::header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap();
assert!(
cc.starts_with("private") && !cc.contains("public"),
"private-mode blobs must be `private`, not `public`; got: {cc}"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) {
let h = common::harness_with_private_mode(pool);

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.124.8",
"version": "0.124.9",
"private": true,
"type": "module",
"scripts": {