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 <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.128.17"
|
version = "0.128.18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.17"
|
version = "0.128.18"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -118,9 +118,20 @@ async fn serve_thumbnail(
|
|||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
// Resizing is CPU-bound; keep it off the async worker threads.
|
// 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
|
.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.
|
// Best-effort cache; a write failure just means we regenerate next time.
|
||||||
let _ = state.storage.put(&derived, &thumb).await;
|
let _ = state.storage.put(&derived, &thumb).await;
|
||||||
|
|||||||
@@ -55,3 +55,20 @@ async fn image_blobs_are_served_inline(pool: sqlx::PgPool) {
|
|||||||
"images must render inline, not download"
|
"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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.17",
|
"version": "0.128.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user