Skip to main content

max / makenotwork

mt: cap image upload while streaming, not after buffering upload_image_handler buffered the whole multipart field via field.bytes() before checking MAX_IMAGE_SIZE, relying solely on a separately-configured DefaultBodyLimit layer that could drift. Read chunk-by-chunk and bail with 413 the instant the running total crosses the cap, so the handler self-defends regardless of routing setup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 14:46 UTC
Commit: 83215f5754f022252317dbae2bfac8783e7a08a5
Parent: 5ebe97e
1 file changed, +29 insertions, -6 deletions
@@ -55,7 +55,7 @@ pub(super) async fn upload_image_handler(
55 55 }
56 56
57 57 // Read the multipart field
58 - let field = multipart
58 + let mut field = multipart
59 59 .next_field()
60 60 .await
61 61 .map_err(|e| {
@@ -67,10 +67,33 @@ pub(super) async fn upload_image_handler(
67 67 let filename = field.file_name().unwrap_or("image").to_string();
68 68 let content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
69 69
70 - let data = field.bytes().await.map_err(|e| {
71 - tracing::error!(error = ?e, "failed to read upload bytes");
72 - (StatusCode::BAD_REQUEST, "Failed to read file.").into_response()
73 - })?;
70 + // Read the field chunk-by-chunk, bailing the instant we cross MAX_IMAGE_SIZE
71 + // rather than buffering the whole field first (`field.bytes()`) and checking
72 + // afterwards. The `/p/{slug}/upload` route already carries a `DefaultBodyLimit`
73 + // (routes/mod.rs), but that is a separately-configured layer that could drift;
74 + // enforcing the same cap here keeps the handler self-defending against a
75 + // memory-DoS regardless of the routing setup. See audit_review.md (uploads
76 + // buffer-before-cap).
77 + let mut data: Vec<u8> = Vec::new();
78 + loop {
79 + match field.chunk().await {
80 + Ok(Some(chunk)) => {
81 + if data.len() + chunk.len() > storage::MAX_IMAGE_SIZE {
82 + return Err((
83 + StatusCode::PAYLOAD_TOO_LARGE,
84 + "Image exceeds the 5 MB limit.",
85 + )
86 + .into_response());
87 + }
88 + data.extend_from_slice(&chunk);
89 + }
90 + Ok(None) => break,
91 + Err(e) => {
92 + tracing::error!(error = ?e, "failed to read upload bytes");
93 + return Err((StatusCode::BAD_REQUEST, "Failed to read file.").into_response());
94 + }
95 + }
96 + }
74 97
75 98 let (ext, validated_ct) = storage::validate_image(&filename, &content_type, &data)
76 99 .map_err(|msg| (StatusCode::UNPROCESSABLE_ENTITY, msg).into_response())?;
@@ -79,7 +102,7 @@ pub(super) async fn upload_image_handler(
79 102 let data = if ext == "jpg" {
80 103 storage::strip_exif_jpeg(&data)
81 104 } else {
82 - data.to_vec()
105 + data
83 106 };
84 107
85 108 let s3_key = storage::generate_image_key(&slug, ext);