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>
75 lines
2.8 KiB
Rust
75 lines
2.8 KiB
Rust
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"
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|