Skip to content

Commit 54efa19

Browse files
committed
fix(s3): add Content-MD5 header for DeleteObjects to fix AWS SDK v3.339.0+ compatibility
AWS SDK PHP v3.339.0+ introduced a breaking change requiring the Content-MD5 header for DeleteObjects operations. This causes 'MissingContentMD5' errors when using S3-compatible services like MinIO. Add middleware to automatically calculate and inject the Content-MD5 header on all DeleteObjects requests. This is applied universally at the S3ConnectionTrait level, fixing both external storage (AmazonS3) and core ObjectStore (S3) classes. Fixes: aws/aws-sdk-php#3068 Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
1 parent d506de9 commit 54efa19

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

lib/private/Files/ObjectStore/S3ConnectionTrait.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
use Aws\Credentials\CredentialProvider;
1111
use Aws\Credentials\Credentials;
1212
use Aws\Exception\CredentialsException;
13+
use Aws\Middleware;
1314
use Aws\S3\Exception\S3Exception;
1415
use Aws\S3\S3Client;
1516
use GuzzleHttp\Promise\Create;
1617
use GuzzleHttp\Promise\RejectedPromise;
18+
use GuzzleHttp\Psr7\Utils;
1719
use OCP\EventDispatcher\IEventDispatcher;
1820
use OCP\Files\ObjectStore\Events\BucketCreatedEvent;
1921
use OCP\Files\StorageNotAvailableException;
@@ -158,6 +160,8 @@ public function getConnection() {
158160
}
159161
$this->connection = new S3Client($options);
160162

163+
$this->addDeleteObjectsContentMd5Middleware();
164+
161165
try {
162166
$logger = Server::get(LoggerInterface::class);
163167
if (!$this->connection::isBucketDnsCompatible($this->bucket)) {
@@ -219,6 +223,40 @@ private function testTimeout() {
219223
}
220224
}
221225

226+
/**
227+
* Add middleware to inject Content-MD5 header for DeleteObjects operations
228+
*
229+
* AWS SDK PHP v3.339.0+ requires Content-MD5 header for DeleteObjects operations.
230+
* This middleware automatically calculates and adds the header to comply with
231+
* AWS S3 API requirements.
232+
*
233+
* @see https://github.com/aws/aws-sdk-php/issues/3068
234+
*/
235+
private function addDeleteObjectsContentMd5Middleware(): void {
236+
if ($this->connection === null) {
237+
return;
238+
}
239+
240+
$handlerList = $this->connection->getHandlerList();
241+
$handlerList->appendBuild(
242+
Middleware::mapRequest(static function ($request) {
243+
// Only add Content-MD5 for DeleteObjects operations
244+
if ($request->getUri()->getQuery() !== 'delete') {
245+
return $request;
246+
}
247+
248+
// Calculate MD5 of request body and add Content-MD5 header
249+
if (!$request->hasHeader('Content-MD5')) {
250+
$body = $request->getBody();
251+
$contentMd5 = base64_encode(Utils::hash($body, 'md5', true));
252+
return $request->withHeader('Content-MD5', $contentMd5);
253+
}
254+
255+
return $request;
256+
})
257+
);
258+
}
259+
222260
public static function legacySignatureProvider($version, $service, $region) {
223261
switch ($version) {
224262
case 'v2':
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace Test\Files\ObjectStore;
11+
12+
use GuzzleHttp\Psr7\Request;
13+
use GuzzleHttp\Psr7\Utils;
14+
use Test\TestCase;
15+
16+
/**
17+
* Test suite for S3 Content-MD5 middleware
18+
* Verifies AWS SDK PHP v3.339.0+ compatibility fix for DeleteObjects operations
19+
* @see https://github.com/aws/aws-sdk-php/issues/3068
20+
*/
21+
#[\PHPUnit\Framework\Attributes\Group('objectstore')]
22+
class S3ContentMd5MiddlewareTest extends TestCase {
23+
24+
/**
25+
* Helper: Apply middleware logic to a request
26+
* Mirrors the logic from S3ConnectionTrait::addDeleteObjectsContentMd5Middleware()
27+
*/
28+
private function applyContentMd5Middleware(Request $request): Request {
29+
if ($request->getUri()->getQuery() !== 'delete') {
30+
return $request;
31+
}
32+
33+
if (!$request->hasHeader('Content-MD5')) {
34+
$body = $request->getBody();
35+
$contentMd5 = base64_encode(Utils::hash($body, 'md5', true));
36+
return $request->withHeader('Content-MD5', $contentMd5);
37+
}
38+
39+
return $request;
40+
}
41+
42+
/**
43+
* Test that Content-MD5 header is added to DeleteObjects requests
44+
*/
45+
public function testContentMd5HeaderAddedToDeleteObjects(): void {
46+
$testBody = '<?xml version="1.0" encoding="UTF-8"?><Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Object><Key>test-key</Key></Object></Delete>';
47+
$request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $testBody);
48+
49+
// Calculate expected MD5
50+
$expectedMd5 = base64_encode(md5($testBody, true));
51+
52+
// Apply middleware logic
53+
$resultRequest = $this->applyContentMd5Middleware($request);
54+
55+
// Verify header was added
56+
$this->assertTrue($resultRequest->hasHeader('Content-MD5'));
57+
$this->assertEquals($expectedMd5, $resultRequest->getHeaderLine('Content-MD5'));
58+
}
59+
60+
/**
61+
* Test that Content-MD5 header is NOT added to non-DeleteObjects requests
62+
*/
63+
public function testContentMd5NotAddedToNonDeleteRequests(): void {
64+
$testCases = [
65+
'GET request' => new Request('GET', 'http://s3.example.com/bucket/key'),
66+
'PUT request' => new Request('PUT', 'http://s3.example.com/bucket/key'),
67+
'HEAD request' => new Request('HEAD', 'http://s3.example.com/bucket/key'),
68+
'POST with different query' => new Request('POST', 'http://s3.example.com/bucket?uploads'),
69+
];
70+
71+
foreach ($testCases as $label => $request) {
72+
$resultRequest = $this->applyContentMd5Middleware($request);
73+
74+
// Verify header was NOT added for non-delete requests
75+
$this->assertFalse($resultRequest->hasHeader('Content-MD5'), "Content-MD5 should not be added for: $label");
76+
}
77+
}
78+
79+
/**
80+
* Test that existing Content-MD5 header is preserved
81+
*/
82+
public function testExistingContentMd5HeaderPreserved(): void {
83+
$testBody = 'test data';
84+
$existingMd5 = 'existing-md5-value';
85+
$request = new Request(
86+
'POST',
87+
'http://s3.example.com/bucket?delete',
88+
['Content-MD5' => $existingMd5],
89+
$testBody
90+
);
91+
92+
// Apply middleware logic
93+
$resultRequest = $this->applyContentMd5Middleware($request);
94+
95+
// Verify existing header was preserved
96+
$this->assertTrue($resultRequest->hasHeader('Content-MD5'));
97+
$this->assertEquals($existingMd5, $resultRequest->getHeaderLine('Content-MD5'));
98+
}
99+
100+
/**
101+
* Test MD5 calculation with various body sizes
102+
*/
103+
public function testMd5CalculationWithVariousSizes(): void {
104+
$testBodies = [
105+
'small' => 'x',
106+
'medium' => str_repeat('y', 1000),
107+
'large' => str_repeat('z', 10000),
108+
'xml_payload' => '<?xml version="1.0"?><Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Object><Key>file1.txt</Key></Object><Object><Key>file2.txt</Key></Object></Delete>',
109+
];
110+
111+
foreach ($testBodies as $label => $body) {
112+
$request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $body);
113+
$expectedMd5 = base64_encode(md5($body, true));
114+
115+
$resultRequest = $this->applyContentMd5Middleware($request);
116+
117+
$this->assertEquals(
118+
$expectedMd5,
119+
$resultRequest->getHeaderLine('Content-MD5'),
120+
"MD5 mismatch for $label body size"
121+
);
122+
}
123+
}
124+
125+
/**
126+
* Test MD5 header format is base64-encoded
127+
*/
128+
public function testMd5HeaderFormatIsBase64(): void {
129+
$testBody = 'test data for base64 validation';
130+
$request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $testBody);
131+
132+
$resultRequest = $this->applyContentMd5Middleware($request);
133+
134+
$md5Header = $resultRequest->getHeaderLine('Content-MD5');
135+
136+
// Verify it's a valid base64 string
137+
$this->assertNotEmpty($md5Header);
138+
$this->assertEquals($md5Header, base64_encode(base64_decode($md5Header, true)));
139+
140+
// Verify MD5 is typically 24 chars when base64-encoded (16 bytes)
141+
$this->assertEquals(24, strlen($md5Header));
142+
}
143+
144+
/**
145+
* Test edge case: Empty body in DeleteObjects request
146+
*/
147+
public function testMd5CalculationWithEmptyBody(): void {
148+
$request = new Request('POST', 'http://s3.example.com/bucket?delete', [], '');
149+
150+
$resultRequest = $this->applyContentMd5Middleware($request);
151+
152+
// MD5 of empty string should still produce a valid header
153+
$this->assertTrue($resultRequest->hasHeader('Content-MD5'));
154+
$this->assertNotEmpty($resultRequest->getHeaderLine('Content-MD5'));
155+
}
156+
157+
/**
158+
* Test that middleware is idempotent (doesn't double-hash)
159+
*/
160+
public function testMiddlewareIsIdempotent(): void {
161+
$testBody = 'test data';
162+
$request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $testBody);
163+
164+
// Apply middleware twice
165+
$resultRequest1 = $this->applyContentMd5Middleware($request);
166+
$resultRequest2 = $this->applyContentMd5Middleware($resultRequest1);
167+
168+
// Headers should be identical
169+
$this->assertEquals(
170+
$resultRequest1->getHeaderLine('Content-MD5'),
171+
$resultRequest2->getHeaderLine('Content-MD5'),
172+
'Middleware should be idempotent'
173+
);
174+
}
175+
}

0 commit comments

Comments
 (0)