From f39307232c31b0cbe260801220ae466bb00b6862 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 20:15:23 +0200 Subject: [PATCH] fix: force download for untyped (octet-stream) served blobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /files handler served the application/octet-stream fallback with no Content-Disposition, letting a browser render it inline — a stored-XSS vector if the blob is crafted HTML/JS. Add Content-Disposition: attachment for that type (belt to the existing nosniff); known image types stay inline. Test (new tests/api_files.rs): octet-stream → attachment; png → inline. Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/api/files.rs | 14 ++++++++-- backend/tests/api_files.rs | 57 ++++++++++++++++++++++++++++++++++++++ frontend/package.json | 2 +- 5 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 backend/tests/api_files.rs 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": {