collapse sequential whitespace in canonical SigV4 header values (#1424)

## Summary

Garage's SigV4 canonical-request builder trims leading/trailing whitespace from signed header values but does not collapse sequential internal whitespace, which the SigV4 spec requires:

> Convert sequential spaces to a single space.

— https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html

AWS SDKs apply this normalization before computing the signature, but transmit the raw value on the wire. The receiver must therefore apply the same normalization when reconstructing the canonical request, otherwise the recomputed hash differs and the request is rejected as `Invalid signature`.

Same class of canonicalization-drift bug as #1155 / !1382, but on the canonical-headers axis rather than the canonical-URI axis.

## Reproduction

Surfaces in practice with `gitlab-runner`'s S3 cache uploader. I was in the midst of migrating my runner cache from AWS S3 to garage, but I noticed some shared runner caches were no longer uploading.

I was using `sha256sum | sha256sum` to compute my cache keys, which leaves a trailing `  -` on the value. Once GitLab appends `-protected` for protected branches the resulting `x-amz-meta-cachekey` header value contains internal sequential whitespace and triggers the mismatch:

```
x-amz-meta-cachekey:php-  --protected
                              ^^
                              two spaces, preserved by Garage
```

Without the fix the included regression test (`test_presigned_put_with_user_metadata`) fails with HTTP 403; with the fix it returns 200.

`aws-cli` is unaffected because it signs `Content-Type` rather than user metadata, so the specific code path with whitespace-bearing signed header values isn't exercised.

## Fix

In `canonical_request` (`src/api/common/signature/payload.rs`), replace the `.trim()` call on the joined header value with the full SigV4 normalization — `split_whitespace().collect::<Vec<_>>().join(" ")` — which both trims edges and collapses internal runs.

## Tests

* New regression test `test_presigned_put_with_user_metadata` covering a  presigned PUT whose `x-amz-meta-*` value contains internal sequential whitespace.
* Full integration suite passes: `40 passed; 0 failed; 2 ignored`.
* `garage_api_common` unit tests pass: `18 passed; 0 failed`.

## Notes

* Backwards-compatible: any signature that validated before still validates, because clients are spec-required to collapse on their side; Garage was only rejecting requests where the client had collapsed correctly but Garage hadn't.
* No config or migration changes.
* Fix applies to both presigned-URL and Authorization-header code paths since they share the canonical-request builder.

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1424
Reviewed-by: Alex <lx@deuxfleurs.fr>
This commit is contained in:
Austin Drummond
2026-04-27 21:15:23 +00:00
committed by Alex
parent d217a3f15d
commit 80f9335950
2 changed files with 52 additions and 2 deletions
+12 -2
View File
@@ -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::<Vec<_>>()
.join(" ");
Ok(format!("{}:{}", name.as_str(), normalized))
})
.collect::<Result<Vec<String>, Error>>()?
.join("\n");
+40
View File
@@ -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);
}