From d89343fb6cd002c7c8ce4e15f9881a8ff8e8292e Mon Sep 17 00:00:00 2001 From: Kamil Holubicki Date: Thu, 10 Sep 2026 11:27:20 +0200 Subject: [PATCH] PS-10416 / PS-11509: Send Content-MD5 header for S3 uploads to support Object Lock https://perconadev.atlassian.net/browse/PS-10416 https://perconadev.atlassian.net/browse/PS-11509 Problem: util.dumpInstance() to an AWS S3 bucket with Object Lock enabled fails because S3 requires a client-side checksum on every payload-bearing PUT that creates a new object version. Without Content-MD5 (or an x-amz-checksum-* header) both single-shot PutObject and multipart UploadPart are rejected with 400 'Content-MD5 OR x-amz-checksum-* HTTP header is required ... with Object Lock parameters'. Cause: Neither PutObject nor UploadPart was emitting a client-side checksum header. Small dumps that fit in a single-shot PutObject and larger dumps that go through the multipart UploadPart path were failing for the same underlying reason. Solution: Inject Content-MD5 once, at signing time, inside the AWS request signer. Every payload-bearing S3 request funnels through that signer - it is where the SigV4 authorization header and the x-amz-content-sha256 payload hash are already assembled - so a single addition there covers both PutObject and UploadPart without touching any bucket, container, or backend class. The injection is guarded so it only fires when the request carries a client-side body, leaving no-body operations (HeadObject, GetObject, DeleteObject, CopyObject, UploadPartCopy, CreateBucket) untouched, and it preserves any Content-MD5 the caller already set - notably the one that S3_bucket::delete_objects computes over its batch-delete XML body. The signature-caching layer already bypasses the cache whenever the request carries a body, so the MD5 is recomputed fresh whenever the payload changes. Anonymous access short-circuits earlier and bypasses signing; that is intentional and safe because S3 rejects anonymous PUT/UploadPart against an Object-Lock bucket as unauthorized before Object Lock is evaluated. --- mysqlshdk/libs/aws/aws_signer.cc | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/mysqlshdk/libs/aws/aws_signer.cc b/mysqlshdk/libs/aws/aws_signer.cc index 4c3eb244ac..a87a683076 100644 --- a/mysqlshdk/libs/aws/aws_signer.cc +++ b/mysqlshdk/libs/aws/aws_signer.cc @@ -30,6 +30,7 @@ #include #include "mysqlshdk/libs/utils/strformat.h" +#include "mysqlshdk/libs/utils/utils_encoding.h" #include "mysqlshdk/libs/utils/utils_ssl.h" #include "mysqlshdk/libs/utils/utils_string.h" @@ -114,6 +115,36 @@ rest::Headers Aws_signer::sign_request(const rest::Signed_request &request, ? hex_sha256({request.body, request.size}) : k_empty_payload_hash; + // AWS S3 with Object Lock rejects PutObject/UploadPart lacking a + // client-side checksum. Every payload-bearing S3 request funnels through + // here, so injecting Content-MD5 at signing time covers both single-shot + // and multipart uploads with no changes elsewhere. try_emplace() leaves + // any header the caller already set intact (e.g. the one that + // S3_bucket::delete_objects computes over its own XML body). + // + // The guard is on request.body rather than request.size: an empty PutObject + // (BUG#34891382, dumper emits 0-byte objects) still creates a new object + // version and Object Lock rejects it too if Content-MD5 is missing. GET, + // HEAD, and no-body PUTs like CopyObject / UploadPartCopy / CreateBucket + // all leave request.body at nullptr, so they are correctly skipped. + // + // Anonymous access (empty access-key-id + secret) short-circuits earlier + // in Signed_request::headers() via Signer::should_sign_request(), so this + // block is skipped for unauthenticated requests. That is intentional and + // safe: S3 rejects anonymous PUT/UploadPart against an Object-Lock bucket + // as unauthorized before Object Lock is ever evaluated, so there is no + // authenticated Object-Lock upload path that could bypass this injection. + // + // See https://perconadev.atlassian.net/browse/PS-10416 and + // https://perconadev.atlassian.net/browse/PS-11509. + if (request.body != nullptr) { + if (auto [it, inserted] = result.try_emplace("Content-MD5"); inserted) { + const auto md5 = shcore::ssl::restricted::md5( + std::string_view(request.body, request.size)); + shcore::encode_base64(md5.data(), md5.size(), &it->second); + } + } + // add required headers result[k_host_header] = m_host; result[k_date_header] = date;