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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.8"
|
version = "0.124.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.124.8"
|
version = "0.124.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
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
|
// revalidation entirely — this is what lets the reader's page and
|
||||||
// next-chapter preloading hit cache instead of re-downloading (and
|
// next-chapter preloading hit cache instead of re-downloading (and
|
||||||
// re-proxying every byte through the SvelteKit node server in prod).
|
// 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,
|
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())
|
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");
|
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")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) {
|
async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) {
|
||||||
let h = common::harness_with_private_mode(pool);
|
let h = common::harness_with_private_mode(pool);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.124.8",
|
"version": "0.124.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user