Files
Mangalord/backend/tests/api_files.rs
MechaCat02 f39307232c fix: force download for untyped (octet-stream) served blobs
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 <noreply@anthropic.com>
2026-07-13 20:15:23 +02:00

58 lines
2.0 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"
);
}