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>
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.8"
|
version = "0.128.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.8"
|
version = "0.128.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ fn image_response(
|
|||||||
content_length: String,
|
content_length: String,
|
||||||
body: Body,
|
body: Body,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let headers = [
|
let mut headers = vec![
|
||||||
(header::CONTENT_TYPE, content_type.to_string()),
|
(header::CONTENT_TYPE, content_type.to_string()),
|
||||||
(header::CONTENT_LENGTH, content_length),
|
(header::CONTENT_LENGTH, content_length),
|
||||||
// `nosniff` makes the contract explicit: the browser must trust the
|
// `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 /
|
/// Parse and clamp a requested thumbnail width. Returns `None` for absent /
|
||||||
|
|||||||
57
backend/tests/api_files.rs
Normal file
57
backend/tests/api_files.rs
Normal file
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.8",
|
"version": "0.128.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user