diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 06475d3..865cd84 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.128.7" +version = "0.128.8" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 468249d..a43cc8c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.128.7" +version = "0.128.8" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/chapters.rs b/backend/src/api/chapters.rs index e5730c0..da4a5d6 100644 --- a/backend/src/api/chapters.rs +++ b/backend/src/api/chapters.rs @@ -13,7 +13,7 @@ use serde::Deserialize; use serde_json::json; use uuid::Uuid; -use crate::api::mangas::{next_field, read_field_bytes}; +use crate::api::mangas::next_field; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::CurrentUser; @@ -107,7 +107,12 @@ async fn create( while let Some(field) = next_field(&mut multipart).await? { match field.name() { Some("metadata") => { - let bytes = read_field_bytes(field).await?; + let bytes = crate::upload::read_capped( + field, + crate::upload::MAX_METADATA_BYTES, + "metadata", + ) + .await?; metadata = Some(serde_json::from_slice(&bytes).map_err(|e| { AppError::ValidationFailed { message: "metadata is not valid JSON".into(), diff --git a/backend/src/api/mangas.rs b/backend/src/api/mangas.rs index db0ef9c..df82ff0 100644 --- a/backend/src/api/mangas.rs +++ b/backend/src/api/mangas.rs @@ -235,7 +235,12 @@ async fn create( while let Some(field) = next_field(&mut multipart).await? { match field.name() { Some("metadata") => { - let bytes = read_field_bytes(field).await?; + let bytes = crate::upload::read_capped( + field, + crate::upload::MAX_METADATA_BYTES, + "metadata", + ) + .await?; metadata = Some(parse_metadata_json(&bytes)?); } Some("cover") => { @@ -677,12 +682,6 @@ pub(crate) async fn next_field( .map_err(map_multipart_error) } -pub(crate) async fn read_field_bytes( - field: axum::extract::multipart::Field<'_>, -) -> AppResult { - field.bytes().await.map_err(map_multipart_error) -} - pub(crate) fn map_multipart_error(e: axum::extract::multipart::MultipartError) -> AppError { let status = e.status(); if status == StatusCode::PAYLOAD_TOO_LARGE { diff --git a/backend/src/upload/mod.rs b/backend/src/upload/mod.rs index d197b9a..e945f02 100644 --- a/backend/src/upload/mod.rs +++ b/backend/src/upload/mod.rs @@ -37,6 +37,15 @@ pub struct StagedImage { /// staged page out of this prefix. pub const STAGING_PREFIX: &str = "staging"; +/// Upper bound on a multipart `metadata` JSON part, enforced as bytes arrive +/// (via [`read_capped`]). Manga/chapter metadata is title + a few short lists + +/// a description — kilobytes at most. Without this, `metadata` was the one +/// remaining part read with the unbounded `Field::bytes()`, letting a client +/// buffer up to the whole 200 MiB request body in memory as a single JSON blob +/// before any validation ran. 64 KiB is generous headroom over any legitimate +/// payload while keeping the worst case tiny. +pub const MAX_METADATA_BYTES: usize = 64 * 1024; + /// Read one multipart image part and write it straight to a staging key, /// returning only its metadata. The per-file byte cap is enforced as bytes /// arrive, so an oversized part is rejected (413) without being fully diff --git a/backend/tests/api_chapters.rs b/backend/tests/api_chapters.rs index e0c60ec..e423175 100644 --- a/backend/tests/api_chapters.rs +++ b/backend/tests/api_chapters.rs @@ -358,3 +358,27 @@ async fn non_owner_can_upload_chapter(pool: PgPool) { assert_eq!(body["title"], "Contributed"); assert_eq!(body["page_count"], 1); } + +#[sqlx::test(migrations = "./migrations")] +async fn chapter_upload_rejects_oversized_metadata_part(pool: PgPool) { + // The chapter `metadata` JSON part is capped as bytes arrive, just like the + // manga one, so a client can't buffer a huge JSON blob in memory. A ~200 KiB + // title blows the metadata cap before any page is staged. + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + let manga_id = seed_manga(&h, &cookie, "Berserk").await; + + let huge = "A".repeat(200 * 1024); + let resp = h + .app + .oneshot(common::post_multipart_with_cookie( + &format!("/api/v1/mangas/{manga_id}/chapters"), + MultipartBuilder::new() + .add_json("metadata", json!({ "number": 1, "title": huge })) + .add_file("page", "1.png", "image/png", &common::fake_png_bytes()), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); +} diff --git a/backend/tests/api_mangas_metadata.rs b/backend/tests/api_mangas_metadata.rs index 892e2de..c9333ad 100644 --- a/backend/tests/api_mangas_metadata.rs +++ b/backend/tests/api_mangas_metadata.rs @@ -927,3 +927,25 @@ async fn bearer_authed_admin_cannot_edit_null_uploader(pool: PgPool) { ); } + +#[sqlx::test(migrations = "./migrations")] +async fn create_rejects_oversized_metadata_part(pool: PgPool) { + // The metadata JSON part is capped well below the 200 MiB request limit so a + // client can't force the server to buffer a huge JSON blob in memory before + // any validation runs. A ~200 KiB description blows the metadata cap. + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let huge = "A".repeat(200 * 1024); + let resp = h + .app + .oneshot(common::post_multipart_with_cookie( + "/api/v1/mangas", + MultipartBuilder::new() + .add_json("metadata", json!({ "title": "Big", "description": huge })), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); +} diff --git a/frontend/package.json b/frontend/package.json index 2d46d9e..5e79fa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.128.7", + "version": "0.128.8", "private": true, "type": "module", "scripts": {