diff --git a/src/api/common/signature/payload.rs b/src/api/common/signature/payload.rs index 532fa04b..72d0516f 100644 --- a/src/api/common/signature/payload.rs +++ b/src/api/common/signature/payload.rs @@ -357,7 +357,13 @@ pub fn canonical_request( items.join("&") }; - // Canonical header string calculated from signed headers + // Canonical header string calculated from signed headers. + // + // Per the SigV4 spec, signed header values must have sequential + // internal whitespace collapsed to a single space, in addition to + // being trimmed. AWS SDKs do this before computing the signature + // but transmit the raw value on the wire, so we must match. + // -> https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html let canonical_header_string = signed_headers .iter() .map(|name| { @@ -372,7 +378,11 @@ pub fn canonical_request( built_string.push(','); built_string.push_str(extend_string); } - Ok(format!("{}:{}", name.as_str(), built_string.trim())) + let normalized = built_string + .split_whitespace() + .collect::>() + .join(" "); + Ok(format!("{}:{}", name.as_str(), normalized)) }) .collect::, Error>>()? .join("\n"); diff --git a/src/garage/tests/s3/presigned.rs b/src/garage/tests/s3/presigned.rs index 15270361..a52b97ef 100644 --- a/src/garage/tests/s3/presigned.rs +++ b/src/garage/tests/s3/presigned.rs @@ -70,3 +70,43 @@ async fn test_presigned_url() { assert_eq!(body, body2); } } + +// Presigned PUT with a user-metadata header whose value contains +// internal sequential whitespace. SigV4 requires collapsing such +// whitespace in canonical header values; missing that normalization +// produces an `Invalid signature` 403 on otherwise-valid requests. +#[tokio::test] +async fn test_presigned_put_with_user_metadata() { + let ctx = common::context(); + let bucket = ctx.create_bucket("presigned-metadata"); + + let key = "cache-archive"; + let metadata_value = "cache-key --protected"; + let body = Bytes::from_static(b"presigned PUT with user metadata"); + + let psc = PresigningConfig::builder() + .start_time(SystemTime::now() - Duration::from_secs(60)) + .expires_in(Duration::from_secs(3600)) + .build() + .unwrap(); + + let presigned = ctx + .client + .put_object() + .bucket(&bucket) + .key(key) + .metadata("cachekey", metadata_value) + .presigned(psc) + .await + .unwrap(); + + let req_builder = Request::builder().method("PUT").uri(presigned.uri()); + let req = presigned + .headers() + .fold(req_builder, |b, (k, v)| b.header(k, v)) + .body(Full::new(body)) + .unwrap(); + + let res = ctx.custom_request.client().request(req).await.unwrap(); + assert_eq!(res.status(), 200); +}