diff --git a/lib/private/Files/ObjectStore/S3ConnectionTrait.php b/lib/private/Files/ObjectStore/S3ConnectionTrait.php index 8dcd57fa63ce3..f3a88d421855c 100644 --- a/lib/private/Files/ObjectStore/S3ConnectionTrait.php +++ b/lib/private/Files/ObjectStore/S3ConnectionTrait.php @@ -10,10 +10,12 @@ use Aws\Credentials\CredentialProvider; use Aws\Credentials\Credentials; use Aws\Exception\CredentialsException; +use Aws\Middleware; use Aws\S3\Exception\S3Exception; use Aws\S3\S3Client; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\RejectedPromise; +use GuzzleHttp\Psr7\Utils; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\ObjectStore\Events\BucketCreatedEvent; use OCP\Files\StorageNotAvailableException; @@ -21,6 +23,7 @@ use OCP\ICacheFactory; use OCP\ICertificateManager; use OCP\Server; +use Psr\Http\Message\RequestInterface; use Psr\Log\LoggerInterface; trait S3ConnectionTrait { @@ -158,6 +161,8 @@ public function getConnection() { } $this->connection = new S3Client($options); + $this->addDeleteObjectsContentMd5Middleware(); + try { $logger = Server::get(LoggerInterface::class); if (!$this->connection::isBucketDnsCompatible($this->bucket)) { @@ -219,6 +224,41 @@ private function testTimeout() { } } + /** + * Add middleware to inject Content-MD5 header for DeleteObjects operations + * + * AWS SDK PHP v3.339.0+ stopped generating the Content-MD5 header for DeleteObjects operations. + * However, this is still required by the `bt-blue.com` S3 provider. + * This middleware automatically calculates and adds the header to comply with + * AWS S3 API requirements. + * + * @see https://github.com/aws/aws-sdk-php/issues/3068 + */ + private function addDeleteObjectsContentMd5Middleware(): void { + if ($this->connection === null) { + return; + } + + $handlerList = $this->connection->getHandlerList(); + $handlerList->appendBuild( + Middleware::mapRequest(static function (RequestInterface $request): RequestInterface { + // Only add Content-MD5 for DeleteObjects operations + if ($request->getUri()->getQuery() !== 'delete') { + return $request; + } + + // Calculate MD5 of request body and add Content-MD5 header + if (!$request->hasHeader('Content-MD5')) { + $body = $request->getBody(); + $contentMd5 = base64_encode(Utils::hash($body, 'md5', true)); + return $request->withHeader('Content-MD5', $contentMd5); + } + + return $request; + }) + ); + } + public static function legacySignatureProvider($version, $service, $region) { switch ($version) { case 'v2': diff --git a/tests/lib/Files/ObjectStore/S3ContentMd5MiddlewareTest.php b/tests/lib/Files/ObjectStore/S3ContentMd5MiddlewareTest.php new file mode 100644 index 0000000000000..4ec9b8c72a94e --- /dev/null +++ b/tests/lib/Files/ObjectStore/S3ContentMd5MiddlewareTest.php @@ -0,0 +1,175 @@ +getUri()->getQuery() !== 'delete') { + return $request; + } + + if (!$request->hasHeader('Content-MD5')) { + $body = $request->getBody(); + $contentMd5 = base64_encode(Utils::hash($body, 'md5', true)); + return $request->withHeader('Content-MD5', $contentMd5); + } + + return $request; + } + + /** + * Test that Content-MD5 header is added to DeleteObjects requests + */ + public function testContentMd5HeaderAddedToDeleteObjects(): void { + $testBody = 'test-key'; + $request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $testBody); + + // Calculate expected MD5 + $expectedMd5 = base64_encode(md5($testBody, true)); + + // Apply middleware logic + $resultRequest = $this->applyContentMd5Middleware($request); + + // Verify header was added + $this->assertTrue($resultRequest->hasHeader('Content-MD5')); + $this->assertEquals($expectedMd5, $resultRequest->getHeaderLine('Content-MD5')); + } + + /** + * Test that Content-MD5 header is NOT added to non-DeleteObjects requests + */ + public function testContentMd5NotAddedToNonDeleteRequests(): void { + $testCases = [ + 'GET request' => new Request('GET', 'http://s3.example.com/bucket/key'), + 'PUT request' => new Request('PUT', 'http://s3.example.com/bucket/key'), + 'HEAD request' => new Request('HEAD', 'http://s3.example.com/bucket/key'), + 'POST with different query' => new Request('POST', 'http://s3.example.com/bucket?uploads'), + ]; + + foreach ($testCases as $label => $request) { + $resultRequest = $this->applyContentMd5Middleware($request); + + // Verify header was NOT added for non-delete requests + $this->assertFalse($resultRequest->hasHeader('Content-MD5'), "Content-MD5 should not be added for: $label"); + } + } + + /** + * Test that existing Content-MD5 header is preserved + */ + public function testExistingContentMd5HeaderPreserved(): void { + $testBody = 'test data'; + $existingMd5 = 'existing-md5-value'; + $request = new Request( + 'POST', + 'http://s3.example.com/bucket?delete', + ['Content-MD5' => $existingMd5], + $testBody + ); + + // Apply middleware logic + $resultRequest = $this->applyContentMd5Middleware($request); + + // Verify existing header was preserved + $this->assertTrue($resultRequest->hasHeader('Content-MD5')); + $this->assertEquals($existingMd5, $resultRequest->getHeaderLine('Content-MD5')); + } + + /** + * Test MD5 calculation with various body sizes + */ + public function testMd5CalculationWithVariousSizes(): void { + $testBodies = [ + 'small' => 'x', + 'medium' => str_repeat('y', 1000), + 'large' => str_repeat('z', 10000), + 'xml_payload' => 'file1.txtfile2.txt', + ]; + + foreach ($testBodies as $label => $body) { + $request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $body); + $expectedMd5 = base64_encode(md5($body, true)); + + $resultRequest = $this->applyContentMd5Middleware($request); + + $this->assertEquals( + $expectedMd5, + $resultRequest->getHeaderLine('Content-MD5'), + "MD5 mismatch for $label body size" + ); + } + } + + /** + * Test MD5 header format is base64-encoded + */ + public function testMd5HeaderFormatIsBase64(): void { + $testBody = 'test data for base64 validation'; + $request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $testBody); + + $resultRequest = $this->applyContentMd5Middleware($request); + + $md5Header = $resultRequest->getHeaderLine('Content-MD5'); + + // Verify it's a valid base64 string + $this->assertNotEmpty($md5Header); + $this->assertEquals($md5Header, base64_encode(base64_decode($md5Header, true))); + + // Verify MD5 is typically 24 chars when base64-encoded (16 bytes) + $this->assertEquals(24, strlen($md5Header)); + } + + /** + * Test edge case: Empty body in DeleteObjects request + */ + public function testMd5CalculationWithEmptyBody(): void { + $request = new Request('POST', 'http://s3.example.com/bucket?delete', [], ''); + + $resultRequest = $this->applyContentMd5Middleware($request); + + // MD5 of empty string should still produce a valid header + $this->assertTrue($resultRequest->hasHeader('Content-MD5')); + $this->assertNotEmpty($resultRequest->getHeaderLine('Content-MD5')); + } + + /** + * Test that middleware is idempotent (doesn't double-hash) + */ + public function testMiddlewareIsIdempotent(): void { + $testBody = 'test data'; + $request = new Request('POST', 'http://s3.example.com/bucket?delete', [], $testBody); + + // Apply middleware twice + $resultRequest1 = $this->applyContentMd5Middleware($request); + $resultRequest2 = $this->applyContentMd5Middleware($resultRequest1); + + // Headers should be identical + $this->assertEquals( + $resultRequest1->getHeaderLine('Content-MD5'), + $resultRequest2->getHeaderLine('Content-MD5'), + 'Middleware should be idempotent' + ); + } +}