Skip to content

Commit 3ae1dd0

Browse files
committed
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.
1 parent 3a9314f commit 3ae1dd0

2 files changed

Lines changed: 31 additions & 17 deletions

File tree

s3/src/command.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,21 @@ impl<'a> Command<'a> {
211211
}
212212
}
213213

214+
/// Whether this command carries a request body that should be reflected
215+
/// in `Content-Length` and `Content-Type` headers during signing.
216+
pub fn has_body(&self) -> bool {
217+
matches!(
218+
self,
219+
Command::PutObject { .. }
220+
| Command::PutObjectTagging { .. }
221+
| Command::UploadPart { .. }
222+
| Command::CompleteMultipartUpload { .. }
223+
| Command::CreateBucket { .. }
224+
| Command::PutBucketLifecycle { .. }
225+
| Command::PutBucketCors { .. }
226+
)
227+
}
228+
214229
pub fn content_length(&self) -> Result<usize, S3Error> {
215230
let result = match &self {
216231
Command::CopyObject { from: _ } => 0,

s3/src/request/request_trait.rs

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -700,23 +700,22 @@ pub trait Request {
700700

701701
headers.insert(HOST, host_header.parse()?);
702702

703-
match self.command() {
704-
Command::CopyObject { from } => {
705-
headers.insert(HeaderName::from_static("x-amz-copy-source"), from.parse()?);
706-
}
707-
Command::ListObjects { .. } => {}
708-
Command::ListObjectsV2 { .. } => {}
709-
Command::GetObject => {}
710-
Command::GetObjectTagging => {}
711-
Command::GetBucketLocation => {}
712-
Command::ListBuckets => {}
713-
_ => {
714-
headers.insert(
715-
CONTENT_LENGTH,
716-
self.command().content_length()?.to_string().parse()?,
717-
);
718-
headers.insert(CONTENT_TYPE, self.command().content_type().parse()?);
719-
}
703+
if let Command::CopyObject { from } = self.command() {
704+
headers.insert(HeaderName::from_static("x-amz-copy-source"), from.parse()?);
705+
}
706+
707+
// Only include content-length and content-type for commands that
708+
// actually carry a request body. Body-less commands (GET, HEAD,
709+
// DELETE, CopyObject, AbortMultipartUpload, etc.) must not have
710+
// these headers in the signed request, otherwise providers like
711+
// Cloudflare R2 reject the signature because the empty
712+
// content-length value corrupts the canonical request.
713+
if self.command().has_body() {
714+
headers.insert(
715+
CONTENT_LENGTH,
716+
self.command().content_length()?.to_string().parse()?,
717+
);
718+
headers.insert(CONTENT_TYPE, self.command().content_type().parse()?);
720719
}
721720
headers.insert(
722721
HeaderName::from_static("x-amz-content-sha256"),

0 commit comments

Comments
 (0)