diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 865cd84..54b61a9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.8" +version = "0.128.9" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a43cc8c..d031638 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.8" +version = "0.128.9" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/files.rs b/backend/src/api/files.rs index 667536c..13caefd 100644 --- a/backend/src/api/files.rs +++ b/backend/src/api/files.rs @@ -141,7 +141,7 @@ fn image_response( content_length: String, body: Body, ) -> Response { - let headers = [ + let mut headers = vec![ (header::CONTENT_TYPE, content_type.to_string()), (header::CONTENT_LENGTH, content_length), // `nosniff` makes the contract explicit: the browser must trust the @@ -169,7 +169,17 @@ fn image_response( }, ), ]; - (headers, body).into_response() + // Known image types render inline (covers/pages display in the reader). The + // `application/octet-stream` fallback is a blob we couldn't type — it could + // be crafted HTML/JS, so force a download rather than let the browser render + // it inline. Belt to `nosniff`'s braces. + if content_type == "application/octet-stream" { + headers.push(( + header::CONTENT_DISPOSITION, + "attachment".to_string(), + )); + } + (axum::response::AppendHeaders(headers), body).into_response() } /// Parse and clamp a requested thumbnail width. Returns `None` for absent / diff --git a/backend/tests/api_files.rs b/backend/tests/api_files.rs new file mode 100644 index 0000000..34c76cb --- /dev/null +++ b/backend/tests/api_files.rs @@ -0,0 +1,57 @@ +mod common; + +use axum::http::StatusCode; +use tower::ServiceExt; + +/// Write a blob straight into the harness storage root at `key`, bypassing the +/// upload handlers — the only way to land a key whose extension resolves to the +/// `application/octet-stream` fallback (uploads always mint image extensions). +fn write_blob(h: &common::Harness, key: &str, bytes: &[u8]) { + let path = h._storage_dir.path().join(key); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +#[sqlx::test(migrations = "./migrations")] +async fn octet_stream_blobs_are_served_as_attachment(pool: sqlx::PgPool) { + // A blob with an unknown extension serves as application/octet-stream. Such a + // body could be crafted HTML/JS, so it must never render inline: force a + // download with Content-Disposition: attachment (nosniff is already set). + let h = common::harness(pool); + write_blob(&h, "misc/blob.bin", b"\x00\x01not-an-image"); + + let resp = h + .app + .oneshot(common::get("/api/v1/files/misc/blob.bin")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/octet-stream" + ); + assert_eq!( + resp.headers().get("content-disposition").unwrap(), + "attachment" + ); +} + +#[sqlx::test(migrations = "./migrations")] +async fn image_blobs_are_served_inline(pool: sqlx::PgPool) { + // Regression guard: known image types keep rendering inline (no attachment + // disposition), so covers/pages still display in the reader. + let h = common::harness(pool); + write_blob(&h, "misc/pic.png", &common::fake_png_bytes()); + + let resp = h + .app + .oneshot(common::get("/api/v1/files/misc/pic.png")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers().get("content-type").unwrap(), "image/png"); + assert!( + resp.headers().get("content-disposition").is_none(), + "images must render inline, not download" + ); +} diff --git a/frontend/package.json b/frontend/package.json index 5e79fa0..daa3bcc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.8", + "version": "0.128.9", "private": true, "type": "module", "scripts": {