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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.124.8"
|
||||
version = "0.124.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.124.8"
|
||||
version = "0.124.9"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.124.8",
|
||||
"version": "0.124.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user