From 042e7e904790b06774f41ddf8c16680f903d50cd Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Mon, 13 Jul 2026 21:41:28 +0200 Subject: [PATCH] fix: fall back to the original when a thumbnail fails to decode A blob with valid magic but a corrupt body passed upload then 500'd on a ?w= thumbnail decode (audit). Fall back to serve_original (streams without decoding) instead. Tested. Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/api/files.rs | 15 +++++++++++++-- backend/tests/api_files.rs | 17 +++++++++++++++++ frontend/package.json | 2 +- 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index af18438..b3aea27 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.17" +version = "0.128.18" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 653dd06..089da93 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.17" +version = "0.128.18" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/files.rs b/backend/src/api/files.rs index 13caefd..b6ad460 100644 --- a/backend/src/api/files.rs +++ b/backend/src/api/files.rs @@ -118,9 +118,20 @@ async fn serve_thumbnail( Err(e) => return Err(e.into()), }; // Resizing is CPU-bound; keep it off the async worker threads. - let thumb = tokio::task::spawn_blocking(move || make_thumbnail(&original, width, fmt)) + let thumb = match tokio::task::spawn_blocking(move || make_thumbnail(&original, width, fmt)) .await - .map_err(|e| AppError::Other(anyhow::anyhow!("thumbnail task join: {e}")))??; + .map_err(|e| AppError::Other(anyhow::anyhow!("thumbnail task join: {e}")))? + { + Ok(t) => t, + Err(e) => { + // The blob's magic bytes passed upload's sniff but the body won't + // decode (corrupt/truncated, or an encoder feature we don't support). + // Don't 500 the reader — fall back to streaming the original, which + // serves without decoding. + tracing::warn!(key, error = %format!("{e:#}"), "thumbnail decode failed; serving original"); + return serve_original(state, key).await; + } + }; // Best-effort cache; a write failure just means we regenerate next time. let _ = state.storage.put(&derived, &thumb).await; diff --git a/backend/tests/api_files.rs b/backend/tests/api_files.rs index 34c76cb..f4c95ae 100644 --- a/backend/tests/api_files.rs +++ b/backend/tests/api_files.rs @@ -55,3 +55,20 @@ async fn image_blobs_are_served_inline(pool: sqlx::PgPool) { "images must render inline, not download" ); } + +#[sqlx::test(migrations = "./migrations")] +async fn corrupt_image_thumbnail_falls_back_to_original_not_500(pool: sqlx::PgPool) { + // A blob with valid PNG magic bytes but an undecodable body passes upload's + // magic-byte sniff; a ?w= thumbnail request then tries to decode it. That + // must not 500 the reader — fall back to streaming the original bytes. + let h = common::harness(pool); + write_blob(&h, "misc/corrupt.png", &common::fake_png_bytes()); + + let resp = h + .app + .oneshot(common::get("/api/v1/files/misc/corrupt.png?w=320")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "corrupt thumbnail must not 500"); + assert_eq!(resp.headers().get("content-type").unwrap(), "image/png"); +} diff --git a/frontend/package.json b/frontend/package.json index 392af2c..47f71dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.17", + "version": "0.128.18", "private": true, "type": "module", "scripts": {