From 64e038d5e6bfbfa013c6466621aabfeec75dc347 Mon Sep 17 00:00:00 2001 From: Ryan Maber Date: Fri, 27 Feb 2026 19:32:17 +0000 Subject: [PATCH 1/2] Handle Amazon header formatting so pre-signed S3 PUT URLs with metadata result in a valid signatures --- src/Core/CHANGELOG.md | 4 ++++ src/Core/src/Signer/SignerV4.php | 30 ++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Core/CHANGELOG.md b/src/Core/CHANGELOG.md index 126736169..d0d986402 100644 --- a/src/Core/CHANGELOG.md +++ b/src/Core/CHANGELOG.md @@ -2,6 +2,10 @@ ## NOT RELEASED +### Fixed + +- SignerV4: Fix presign PUT requests for S3 objects not passing signature validation + ## 1.28.1 ### Changed diff --git a/src/Core/src/Signer/SignerV4.php b/src/Core/src/Signer/SignerV4.php index f4232e52b..128499ceb 100644 --- a/src/Core/src/Signer/SignerV4.php +++ b/src/Core/src/Signer/SignerV4.php @@ -244,7 +244,8 @@ private function convertHeaderToQuery(Request $request): void { foreach ($request->getHeaders() as $name => $value) { if ('x-amz' === substr($name, 0, 5)) { - $attribute = implode('-', array_map(ucfirst(...), explode('-', $name))); + $attribute = self::formatAmazonHeader($name); + $request->setQueryAttribute($attribute, $value); } @@ -309,7 +310,10 @@ private function buildCanonicalRequest(Request $request, array $canonicalHeaders $this->buildCanonicalQuery($request), implode("\n", array_values($canonicalHeaders)), '', // empty line after headers - implode(';', array_keys($canonicalHeaders)), + implode( + ';', + array_keys($canonicalHeaders) + ), $bodyDigest, ]); } @@ -371,4 +375,26 @@ private function buildSignature(string $stringToSign, string $signingKey): strin { return hash_hmac('sha256', $stringToSign, $signingKey); } + + private static function formatAmazonHeader(string $key): string + { + if ( + 'x-amz' === substr($key, 0, 5) + && 'x-amz-meta' !== substr($key, 0, 10) + ) { + /** + * In order to maintain compatability with S3-like APIs we need to compute the + * signature inline with the official SDKs behaviour - which means Amazon's headers + * should look like: `X-Amz-Content-Sha256`. + * + * Worth noting this **does not** include S3 object's user-defined metadata, which should + * remain prefixed as `x-amz-meta-` (all lowercase). + * + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata + */ + return implode('-', array_map(ucfirst(...), explode('-', $key))); + } + + return $key; + } } From 17df2fc5f29553ad2341d051617735a886e9ec6d Mon Sep 17 00:00:00 2001 From: Ryan Maber Date: Fri, 27 Feb 2026 20:07:34 +0000 Subject: [PATCH 2/2] Add a test --- src/Core/tests/Unit/Signer/SignerV4Test.php | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Core/tests/Unit/Signer/SignerV4Test.php b/src/Core/tests/Unit/Signer/SignerV4Test.php index 13427969a..42a510757 100644 --- a/src/Core/tests/Unit/Signer/SignerV4Test.php +++ b/src/Core/tests/Unit/Signer/SignerV4Test.php @@ -61,6 +61,33 @@ public function testPresign() self::assertEqualsCanonicalizing($expectedQuery, $request->getQuery()); } + public function testPresignS3UserDefinedMetadata() + { + $signer = new SignerV4('sqs', 'eu-west-1'); + + $request = new Request('POST', '/foo', ['arg' => 'bar'], ['header' => 'baz', 'x-amz-meta-keyid1' => '1', 'x-amz-meta-keyId2' => '2'], StringStream::create('body')); + $request->setEndpoint('http://localhost:1234/foo?arg=bar'); + $context = new RequestContext(['currentDate' => new \DateTimeImmutable('2020-01-01T00:00:00Z')]); + $credentials = new Credentials('key', 'secret', 'token'); + + $signer->presign($request, $credentials, $context); + + $expectedQuery = [ + '1', + '2', + '20200101T000000Z', + '2f7f3d47aaed21ef48cd09a47e09c14355e959c8d480e972fdce55a699ae727c', + '3600', + 'AWS4-HMAC-SHA256', + 'bar', + 'header;host;x-amz-meta-keyid1;x-amz-meta-keyid2', + 'key/20200101/eu-west-1/sqs/aws4_request', + 'token', + ]; + + self::assertEqualsCanonicalizing($expectedQuery, $request->getQuery()); + } + #[DataProvider('provideRequests')] public function testSignsRequests($rawRequest, $rawExpected) {