Skip to content
/ core Public

Commit 809a83d

Browse files
authored
Fix PresignedUrl issue with case convertion of header names (#2057)
1 parent 5383288 commit 809a83d

6 files changed

Lines changed: 112 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
- AwsClientFactory: add `ec2()` method for the new `async-aws/ec2` client
99
- XmlAwsErrorFactory: parse EC2-style error envelopes (`<Response><Errors><Error>...`)
1010

11+
### Fixed
12+
13+
- SignerV4: Fix presign request for S3 when request contains user-defined metadata
14+
1115
## 1.28.1
1216

1317
### Changed

src/AbstractApi.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ final protected function getResponse(Request $request, ?RequestContext $context
152152

153153
$length = $request->getBody()->length();
154154
if (null !== $length && !$request->hasHeader('content-length')) {
155-
$request->setHeader('content-length', (string) $length);
155+
$request->setHeader('Content-Length', (string) $length);
156156
}
157157

158158
// Some servers (like testing Docker Images) does not support `Transfer-Encoding: chunked` requests.

src/Request.php

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
final class Request
1616
{
1717
use BuildHttpQueryTrait;
18+
private const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
19+
private const LOWER = '-abcdefghijklmnopqrstuvwxyz';
1820

1921
/**
2022
* @var string
@@ -31,6 +33,11 @@ final class Request
3133
*/
3234
private $headers;
3335

36+
/**
37+
* @var array<string, string> Map of lowercase header name => original name at registration
38+
*/
39+
private $headerNames = [];
40+
3441
/**
3542
* @var RequestStream
3643
*/
@@ -70,8 +77,10 @@ public function __construct(string $method, string $uri, array $query, array $he
7077
$this->method = $method;
7178
$this->uri = $uri;
7279
$this->headers = [];
73-
foreach ($headers as $key => $value) {
74-
$this->headers[strtolower($key)] = (string) $value;
80+
foreach ($headers as $name => $value) {
81+
$normalized = strtr($name, self::UPPER, self::LOWER);
82+
$this->headerNames[$normalized] = $name;
83+
$this->headers[$name] = (string) $value;
7584
}
7685
$this->body = $body;
7786
$this->query = $query;
@@ -96,12 +105,20 @@ public function getUri(): string
96105

97106
public function hasHeader(string $name): bool
98107
{
99-
return \array_key_exists(strtolower($name), $this->headers);
108+
$normalized = strtr($name, self::UPPER, self::LOWER);
109+
110+
return isset($this->headerNames[$normalized]);
100111
}
101112

102113
public function setHeader(string $name, string $value): void
103114
{
104-
$this->headers[strtolower($name)] = $value;
115+
$normalized = strtr($name, self::UPPER, self::LOWER);
116+
117+
if (isset($this->headerNames[$normalized])) {
118+
unset($this->headers[$this->headerNames[$normalized]]);
119+
}
120+
$this->headerNames[$normalized] = $name;
121+
$this->headers[$name] = $value;
105122
}
106123

107124
/**
@@ -114,12 +131,25 @@ public function getHeaders(): array
114131

115132
public function getHeader(string $name): ?string
116133
{
117-
return $this->headers[strtolower($name)] ?? null;
134+
$normalized = strtr($name, self::UPPER, self::LOWER);
135+
if (!isset($this->headerNames[$normalized])) {
136+
return null;
137+
}
138+
139+
$name = $this->headerNames[$normalized];
140+
141+
return $this->headers[$name] ?? null;
118142
}
119143

120144
public function removeHeader(string $name): void
121145
{
122-
unset($this->headers[strtolower($name)]);
146+
$normalized = strtr($name, self::UPPER, self::LOWER);
147+
if (!isset($this->headerNames[$normalized])) {
148+
return;
149+
}
150+
151+
$name = $this->headerNames[$normalized];
152+
unset($this->headers[$name], $this->headerNames[$normalized]);
123153
}
124154

125155
public function getBody(): RequestStream

src/Signer/SignerV4.php

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ public function sign(Request $request, Credentials $credentials, RequestContext
8484

8585
protected function buildBodyDigest(Request $request, bool $isPresign): string
8686
{
87-
if ($request->hasHeader('x-amz-content-sha256')) {
87+
if ($request->hasHeader('X-Amz-Content-Sha256')) {
8888
/** @var string $hash */
89-
$hash = $request->getHeader('x-amz-content-sha256');
89+
$hash = $request->getHeader('X-Amz-Content-Sha256');
9090
} else {
9191
$body = $request->getBody();
9292
if ($body instanceof ReadOnceResultStream) {
@@ -97,7 +97,7 @@ protected function buildBodyDigest(Request $request, bool $isPresign): string
9797
}
9898

9999
if ('UNSIGNED-PAYLOAD' === $hash) {
100-
$request->setHeader('x-amz-content-sha256', $hash);
100+
$request->setHeader('X-Amz-Content-Sha256', $hash);
101101
}
102102

103103
return $hash;
@@ -140,7 +140,7 @@ private function handleSignature(Request $request, Credentials $credentials, \Da
140140
$bodyDigest = $this->buildBodyDigest($request, $isPresign);
141141

142142
if ($isPresign) {
143-
// Should be called after `buildBodyDigest` because this method moves the header `x-amz-content-sha256` in the querystring
143+
// Should be called after `buildBodyDigest` because this method moves the header `X-Amz-Content-Sha256` in the querystring
144144
$this->convertHeaderToQuery($request);
145145
}
146146

@@ -152,7 +152,7 @@ private function handleSignature(Request $request, Credentials $credentials, \Da
152152
if ($isPresign) {
153153
$request->setQueryAttribute('X-Amz-Signature', $signature);
154154
} else {
155-
$request->setHeader('authorization', \sprintf(
155+
$request->setHeader('Authorization', \sprintf(
156156
'%s Credential=%s/%s, SignedHeaders=%s, Signature=%s',
157157
self::ALGORITHM_REQUEST,
158158
$credentials->getAccessKeyId(),
@@ -189,7 +189,7 @@ private function sanitizeHostForHeader(Request $request): void
189189
$host .= ':' . $parsedUrl['port'];
190190
}
191191

192-
$request->setHeader('host', $host);
192+
$request->setHeader('Host', $host);
193193
}
194194

195195
private function assignAmzQueryValues(Request $request, Credentials $credentials, bool $isPresign): void
@@ -204,7 +204,7 @@ private function assignAmzQueryValues(Request $request, Credentials $credentials
204204
}
205205

206206
if (null !== $sessionToken = $credentials->getSessionToken()) {
207-
$request->setHeader('x-amz-security-token', $sessionToken);
207+
$request->setHeader('X-Amz-Security-Token', $sessionToken);
208208
}
209209
}
210210

@@ -243,16 +243,16 @@ private function buildCredentialString(Request $request, Credentials $credential
243243
private function convertHeaderToQuery(Request $request): void
244244
{
245245
foreach ($request->getHeaders() as $name => $value) {
246-
if ('x-amz' === substr($name, 0, 5)) {
247-
$attribute = implode('-', array_map(ucfirst(...), explode('-', $name)));
248-
$request->setQueryAttribute($attribute, $value);
246+
$lowerName = strtolower($name);
247+
if (str_starts_with($lowerName, 'x-amz')) {
248+
$request->setQueryAttribute($name, $value);
249249
}
250250

251-
if (isset(self::BLACKLIST_HEADERS[$name])) {
251+
if (isset(self::BLACKLIST_HEADERS[$lowerName])) {
252252
$request->removeHeader($name);
253253
}
254254
}
255-
$request->removeHeader('x-amz-content-sha256');
255+
$request->removeHeader('X-Amz-Content-Sha256');
256256
}
257257

258258
private function convertBodyToQuery(Request $request): void

src/Test/TestCase.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ public static function assertRequestEqualsHttpRequest(string $expected, Request
6969
$value = $parts[1] ?? '';
7070
$expectedHeaders[strtolower($key)] = trim($value);
7171
}
72-
self::assertEqualsIgnoringCase($expectedHeaders, $actual->getHeaders(), $message);
72+
$actualHeaders = [];
73+
foreach ($actual->getHeaders() as $key => $value) {
74+
$actualHeaders[strtolower($key)] = $value;
75+
}
76+
self::assertEqualsIgnoringCase($expectedHeaders, $actualHeaders, $message);
7377

7478
switch ($expectedHeaders['content-type'] ?? null) {
7579
case 'application/x-www-form-urlencoded':

tests/Unit/Signer/SignerV4Test.php

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,33 @@ public function testSign()
2727

2828
$expectedHeaders = [
2929
'header' => 'baz',
30-
'host' => 'localhost:1234',
31-
'x-amz-security-token' => 'token',
32-
'x-amz-date' => '20200101T000000Z',
33-
'authorization' => 'AWS4-HMAC-SHA256 Credential=key/20200101/eu-west-1/sqs/aws4_request, SignedHeaders=header;host;x-amz-date;x-amz-security-token, Signature=87e3d70ecfaf7655c24284198c90c61da166aea5bbe2eb3fe470634369acb108',
30+
'Host' => 'localhost:1234',
31+
'X-Amz-Security-Token' => 'token',
32+
'X-Amz-Date' => '20200101T000000Z',
33+
'Authorization' => 'AWS4-HMAC-SHA256 Credential=key/20200101/eu-west-1/sqs/aws4_request, SignedHeaders=header;host;x-amz-date;x-amz-security-token, Signature=87e3d70ecfaf7655c24284198c90c61da166aea5bbe2eb3fe470634369acb108',
34+
];
35+
36+
self::assertEqualsCanonicalizing($expectedHeaders, $request->getHeaders());
37+
}
38+
39+
public function testSignWithCustomMetadata()
40+
{
41+
$signer = new SignerV4('sqs', 'eu-west-1');
42+
43+
$request = new Request('POST', '/foo', ['arg' => 'bar'], ['header' => 'baz', 'x-amz-meta-foo' => 'qux'], StringStream::create('body'));
44+
$request->setEndpoint('http://localhost:1234/foo?arg=bar');
45+
$context = new RequestContext(['currentDate' => new \DateTimeImmutable('2020-01-01T00:00:00Z')]);
46+
$credentials = new Credentials('key', 'secret', 'token');
47+
48+
$signer->sign($request, $credentials, $context);
49+
50+
$expectedHeaders = [
51+
'header' => 'baz',
52+
'x-amz-meta-foo' => 'qux',
53+
'Host' => 'localhost:1234',
54+
'X-Amz-Security-Token' => 'token',
55+
'X-Amz-Date' => '20200101T000000Z',
56+
'Authorization' => 'AWS4-HMAC-SHA256 Credential=key/20200101/eu-west-1/sqs/aws4_request, SignedHeaders=header;host;x-amz-date;x-amz-meta-foo;x-amz-security-token, Signature=ca51c7ab9267efedb93598add21a9bd8e354471c7cbfdccbb5309b6da1fcc74a',
3457
];
3558

3659
self::assertEqualsCanonicalizing($expectedHeaders, $request->getHeaders());
@@ -61,6 +84,32 @@ public function testPresign()
6184
self::assertEqualsCanonicalizing($expectedQuery, $request->getQuery());
6285
}
6386

87+
public function testPresignWithCustomMetadata()
88+
{
89+
$signer = new SignerV4('sqs', 'eu-west-1');
90+
91+
$request = new Request('POST', '/foo', ['arg' => 'bar'], ['header' => 'baz', 'x-amz-meta-foo' => 'qux'], StringStream::create('body'));
92+
$request->setEndpoint('http://localhost:1234/foo?arg=bar');
93+
$context = new RequestContext(['currentDate' => new \DateTimeImmutable('2020-01-01T00:00:00Z')]);
94+
$credentials = new Credentials('key', 'secret', 'token');
95+
96+
$signer->presign($request, $credentials, $context);
97+
98+
$expectedQuery = [
99+
'arg' => 'bar',
100+
'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
101+
'X-Amz-Security-Token' => 'token',
102+
'X-Amz-Date' => '20200101T000000Z',
103+
'X-Amz-Expires' => '3600',
104+
'X-Amz-Credential' => 'key/20200101/eu-west-1/sqs/aws4_request',
105+
'x-amz-meta-foo' => 'qux',
106+
'X-Amz-SignedHeaders' => 'header;host;x-amz-meta-foo',
107+
'X-Amz-Signature' => '03af97bd38e7310c6335aedce2f588fe3714240bcc390c253b8346f1380df909',
108+
];
109+
110+
self::assertEqualsCanonicalizing($expectedQuery, $request->getQuery());
111+
}
112+
64113
#[DataProvider('provideRequests')]
65114
public function testSignsRequests($rawRequest, $rawExpected)
66115
{
@@ -134,7 +183,7 @@ public static function provideRequests()
134183
// DateHeader should be kept
135184
[
136185
"POST / HTTP/1.1\r\nHost: host.foo.com:443\r\nx-AMZ-date: 20110909T233600Z\r\nExpires: Thu, 21 May 20 20:54:15 +0200\r\n\r\n",
137-
"POST / HTTP/1.1\r\nHost: host.foo.com:443\r\nexpires:Thu, 21 May 20 20:54:15 +0200\r\nX-Amz-Date: 20110909T233600Z\r\nAuthorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=expires;host;x-amz-date, Signature=7090e12acc44281b2b46ba195ee1ae09f2e8c81653fcd592abbfbc30e1a5acc6\r\n\r\n",
186+
"POST / HTTP/1.1\r\nHost: host.foo.com:443\r\nExpires:Thu, 21 May 20 20:54:15 +0200\r\nX-Amz-Date: 20110909T233600Z\r\nAuthorization: AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/aws4_request, SignedHeaders=expires;host;x-amz-date, Signature=7090e12acc44281b2b46ba195ee1ae09f2e8c81653fcd592abbfbc30e1a5acc6\r\n\r\n",
138187
],
139188
];
140189
}

0 commit comments

Comments
 (0)