From 36fedf7b4b6b13fea29edaf76d20140eef11aba8 Mon Sep 17 00:00:00 2001 From: w4nderlust Date: Mon, 6 Apr 2026 09:48:55 -0700 Subject: [PATCH 1/4] Fix ranged GET signing failure with Cloudflare R2 GetObjectRange (and other body-less commands like DeleteObject, AbortMultipartUpload, etc.) were getting content-length and content-type headers included in the signed request. For commands with no body these headers are empty/meaningless, but they still end up in the canonical request signature. Cloudflare R2 rejects the resulting signature (SignatureDoesNotMatch), while AWS S3 happens to tolerate it. Added a `has_body()` helper on Command that returns true only for commands that actually serialize request content (PutObject, UploadPart, CompleteMultipartUpload, etc.). The header insertion in request_trait.rs now checks `has_body()` instead of matching on the HTTP verb, which avoids signing empty headers for any current or future body-less command. --- s3/src/command.rs | 15 ++++++++++++++ s3/src/request/request_trait.rs | 36 ++++++++++++++++----------------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/s3/src/command.rs b/s3/src/command.rs index 4b57486af8..6cb841df14 100644 --- a/s3/src/command.rs +++ b/s3/src/command.rs @@ -215,6 +215,21 @@ impl<'a> Command<'a> { } } + /// Whether this command carries a request body that should be reflected + /// in `Content-Length` and `Content-Type` headers during signing. + pub fn has_body(&self) -> bool { + matches!( + self, + Command::PutObject { .. } + | Command::PutObjectTagging { .. } + | Command::UploadPart { .. } + | Command::CompleteMultipartUpload { .. } + | Command::CreateBucket { .. } + | Command::PutBucketLifecycle { .. } + | Command::PutBucketCors { .. } + ) + } + pub fn content_length(&self) -> Result { let result = match &self { Command::CopyObject { from: _ } => 0, diff --git a/s3/src/request/request_trait.rs b/s3/src/request/request_trait.rs index 9493504236..12e5a6149a 100644 --- a/s3/src/request/request_trait.rs +++ b/s3/src/request/request_trait.rs @@ -730,24 +730,24 @@ pub trait Request { headers.insert(HOST, host_header.parse()?); - match self.command() { - Command::CopyObject { from } => { - headers.insert(HeaderName::from_static("x-amz-copy-source"), from.parse()?); - } - Command::ListObjects { .. } => {} - Command::ListObjectsV2 { .. } => {} - Command::HeadObject => {} - Command::GetObject => {} - Command::GetObjectTagging => {} - Command::GetBucketLocation => {} - Command::ListBuckets => {} - _ => { - headers.insert( - CONTENT_LENGTH, - self.command().content_length()?.to_string().parse()?, - ); - headers.insert(CONTENT_TYPE, self.command().content_type().parse()?); - } + if let Command::CopyObject { from } = self.command() { + headers.insert(HeaderName::from_static("x-amz-copy-source"), from.parse()?); + } + + // Include content-length and content-type only for commands that + // either carry a request body or are otherwise required by some + // providers to advertise these headers (see Command::has_body). + // Body-less GET/HEAD/DELETE/CopyObject must not sign these headers, + // otherwise Cloudflare R2 rejects the AWS4-HMAC-SHA256 signature + // because the empty content-length value corrupts the canonical + // request. InitiateMultipartUpload is included because GCS returns + // HTTP 411 on a POST without Content-Length, even with an empty body. + if self.command().has_body() { + headers.insert( + CONTENT_LENGTH, + self.command().content_length()?.to_string().parse()?, + ); + headers.insert(CONTENT_TYPE, self.command().content_type().parse()?); } headers.insert( HeaderName::from_static("x-amz-content-sha256"), From 52881b0b744ee0dda1008c89cd5f8d804b01c593 Mon Sep 17 00:00:00 2001 From: w4nderlust Date: Wed, 6 May 2026 14:58:49 -0700 Subject: [PATCH 2/4] Include InitiateMultipartUpload in has_body() for GCS compatibility GCS rejects POST requests without a Content-Length header (HTTP 411), even when the body is empty. The previous has_body() definition omitted Content-Length for InitiateMultipartUpload, regressing streaming multipart uploads against GCS. Add InitiateMultipartUpload to has_body() so Content-Length: 0 is sent on the initiate-multipart POST. Document why the helper covers a body-less command. Add provider-agnostic unit tests for has_body() covering ranged GET, body-less DELETE/CopyObject, InitiateMultipartUpload, and body-bearing commands. --- s3/src/command.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/s3/src/command.rs b/s3/src/command.rs index 6cb841df14..0953181494 100644 --- a/s3/src/command.rs +++ b/s3/src/command.rs @@ -215,14 +215,31 @@ impl<'a> Command<'a> { } } - /// Whether this command carries a request body that should be reflected - /// in `Content-Length` and `Content-Type` headers during signing. + /// Whether this command should include `Content-Length` and `Content-Type` + /// headers in the signed request. + /// + /// Returns `true` for commands that serialize a request body, plus + /// `InitiateMultipartUpload`. The latter is a `POST` with an empty body + /// but is included because: + /// + /// - Google Cloud Storage rejects the request with HTTP 411 if + /// `Content-Length` is omitted from a `POST`, even when the body is + /// empty. + /// - The `Content-Type` value carried by `InitiateMultipartUpload` is + /// not a description of the (empty) request body but the content type + /// to associate with the eventual multipart object on the server. + /// + /// Body-less `GET`, `HEAD`, and `DELETE` commands return `false` so that + /// stray `Content-Length: 0` / `Content-Type: text/plain` headers do + /// not enter the AWS4-HMAC-SHA256 canonical request, which Cloudflare + /// R2 rejects as a signature mismatch (notably for ranged `GET`s). pub fn has_body(&self) -> bool { matches!( self, Command::PutObject { .. } | Command::PutObjectTagging { .. } | Command::UploadPart { .. } + | Command::InitiateMultipartUpload { .. } | Command::CompleteMultipartUpload { .. } | Command::CreateBucket { .. } | Command::PutBucketLifecycle { .. } @@ -383,3 +400,65 @@ impl<'a> Command<'a> { Ok(result) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Body-less `GET`s (notably ranged `GET`) must not advertise body + /// headers, otherwise Cloudflare R2 rejects the AWS4-HMAC-SHA256 + /// signature for ranged downloads. + #[test] + fn ranged_get_does_not_have_body() { + let cmd = Command::GetObjectRange { + start: 0, + end: Some(1023), + }; + assert!(!cmd.has_body()); + assert!(!Command::GetObject.has_body()); + assert!(!Command::HeadObject.has_body()); + assert!(!Command::ListBuckets.has_body()); + } + + /// `DELETE` and `CopyObject` carry no request body. + #[test] + fn delete_and_copy_do_not_have_body() { + assert!(!Command::DeleteObject.has_body()); + assert!(!Command::AbortMultipartUpload { upload_id: "u" }.has_body()); + assert!(!Command::CopyObject { from: "x" }.has_body()); + } + + /// `InitiateMultipartUpload` is body-less but must still be reported as + /// having a body so that `Content-Length: 0` is sent. GCS returns HTTP + /// 411 on `POST` requests without `Content-Length`, even when the body + /// is empty. + #[test] + fn initiate_multipart_upload_has_body_for_gcs_compat() { + let cmd = Command::InitiateMultipartUpload { + content_type: "application/octet-stream", + }; + assert!(cmd.has_body()); + assert_eq!(cmd.http_verb(), HttpMethod::Post); + assert_eq!(cmd.content_length().unwrap(), 0); + } + + /// Body-bearing commands report `has_body() == true` so signing + /// includes accurate `Content-Length` / `Content-Type`. + #[test] + fn body_bearing_commands_have_body() { + let put = Command::PutObject { + content: b"hello", + content_type: "text/plain", + custom_headers: None, + multipart: None, + }; + assert!(put.has_body()); + + let upload = Command::UploadPart { + part_number: 1, + content: b"data", + upload_id: "u", + }; + assert!(upload.has_body()); + } +} From c86795ce79383ba0a3dea7f0448cb6c8d7069844 Mon Sep 17 00:00:00 2001 From: w4nderlust Date: Wed, 6 May 2026 16:20:39 -0700 Subject: [PATCH 3/4] Include DeleteObjects in has_body() DeleteObjects is a POST with an XML body listing the keys to delete (Content-Type: application/xml, content_length > 0). It was missing from has_body(), so multi-object delete requests would have been signed without Content-Length/Content-Type, causing failures and signature mismatches on strict providers. Add a unit test pinning the body-bearing classification, content type, and non-zero content length. --- s3/src/command.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/s3/src/command.rs b/s3/src/command.rs index 0953181494..119b8328a7 100644 --- a/s3/src/command.rs +++ b/s3/src/command.rs @@ -244,6 +244,7 @@ impl<'a> Command<'a> { | Command::CreateBucket { .. } | Command::PutBucketLifecycle { .. } | Command::PutBucketCors { .. } + | Command::DeleteObjects { .. } ) } @@ -461,4 +462,26 @@ mod tests { }; assert!(upload.has_body()); } + + /// `DeleteObjects` is a `POST` with an XML body listing the keys to + /// delete. It must be reported as body-bearing so `Content-Length` + /// reflects the payload size and `Content-Type: application/xml` is + /// signed; otherwise providers reject the request or the signature. + #[test] + fn delete_objects_has_body() { + use crate::serde_types::{DeleteObjectsRequest, ObjectIdentifier}; + let cmd = Command::DeleteObjects { + data: DeleteObjectsRequest { + objects: vec![ObjectIdentifier { + key: "a".to_string(), + version_id: None, + }], + quiet: false, + }, + }; + assert!(cmd.has_body()); + assert_eq!(cmd.http_verb(), HttpMethod::Post); + assert!(cmd.content_length().unwrap() > 0); + assert_eq!(cmd.content_type(), "application/xml"); + } } From 2936eb7e856655a4a49a65377e3cdb59e29fea71 Mon Sep 17 00:00:00 2001 From: w4nderlust Date: Wed, 6 May 2026 20:25:34 -0700 Subject: [PATCH 4/4] Add doctest example to Command::has_body() --- s3/src/command.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/s3/src/command.rs b/s3/src/command.rs index 119b8328a7..28762a1e39 100644 --- a/s3/src/command.rs +++ b/s3/src/command.rs @@ -233,6 +233,22 @@ impl<'a> Command<'a> { /// stray `Content-Length: 0` / `Content-Type: text/plain` headers do /// not enter the AWS4-HMAC-SHA256 canonical request, which Cloudflare /// R2 rejects as a signature mismatch (notably for ranged `GET`s). + /// + /// # Examples + /// + /// ``` + /// use s3::command::Command; + /// + /// assert!(Command::PutObject { + /// content: b"hi", + /// content_type: "text/plain", + /// custom_headers: None, + /// multipart: None, + /// } + /// .has_body()); + /// assert!(!Command::GetObject.has_body()); + /// assert!(!Command::GetObjectRange { start: 0, end: Some(1023) }.has_body()); + /// ``` pub fn has_body(&self) -> bool { matches!( self,