fix: forward the legal hold as an Object Lock header on federated CopyObject

A cross-deployment CopyObject that requests
`x-amz-object-lock-legal-hold: ON` answered 200 while the destination
carried no hold. The resolved value reached the remote as ordinary user
metadata, `X-Amz-Meta-X-Amz-Object-Lock-Legal-Hold`, so nothing applied it.
Retention requested on the same copy survived, which is what made the loss
easy to miss.

The federation branch passes the resolved metadata map straight to
`Core.PutObject` as `PutObjectOptions.UserMetadata`. minio-go's `Header()`
writes the typed lock fields first, then prefixes every UserMetadata key it
does not recognise with `x-amz-meta-`; `supportedHeaders` covers
`x-amz-object-lock-mode` and `x-amz-object-lock-retain-until-date` but not
`x-amz-object-lock-legal-hold`, and `isAmzHeader` does not match it either.
Retention therefore arrives as real headers and the hold does not. The
high-level `validate()` that would have rejected the key never runs, because
`Core.PutObject` goes straight to the low-level PUT.

Carry the hold on the typed `LegalHold` option and forward a cloned map with
the raw key removed. The clone matters twice: typed fields are written before
the UserMetadata loop, so a leftover raw key would add a bogus `x-amz-meta-`
entry beside the correct header, and the proxy's own response and event
metadata are rebuilt from the resolved values rather than the forwarding map,
which no longer carries the hold.

Retention stays in the map deliberately. It already passes through as a
standard header, and moving it to the typed `RetainUntilDate` field would
format with `time.RFC3339` and truncate a retain-until date to whole seconds.

The new test asserts the wire: the remote must receive
`X-Amz-Object-Lock-Legal-Hold` and never the `x-amz-meta-` spelling, and the
destination version must actually store the hold. It fails without the change
with "legal hold forwarded as user metadata [ON]".

Fixes #166

Signed-off-by: Ayush Sharma <72848455+Aeirx@users.noreply.github.com>
This commit is contained in:
Ayush Sharma
2026-09-09 18:42:07 +05:30
parent 2f61325d4a
commit e7e87402ed
2 changed files with 169 additions and 2 deletions
+30 -2
View File
@@ -1945,11 +1945,36 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
delete(srcInfo.UserDefined, k)
}
}
// Legal hold does not survive the metadata map. minio-go's Header()
// writes the typed lock fields first, then prefixes every UserMetadata
// key it does not recognise with "x-amz-meta-": supportedHeaders lists
// x-amz-object-lock-mode and x-amz-object-lock-retain-until-date but not
// x-amz-object-lock-legal-hold, and isAmzHeader does not match it
// either. Forwarded in the map the hold arrives as
// X-Amz-Meta-X-Amz-Object-Lock-Legal-Hold, the destination stores no
// hold, and the copy still answers 200 (#166).
//
// Carry only the hold on the typed field, and forward a clone without
// the raw key: typed fields are written before the UserMetadata loop, so
// a leftover raw key would add a bogus x-amz-meta- entry beside the
// correct header. Retention stays in the map -- it already passes
// through as a standard header, and moving it to the typed
// RetainUntilDate field would truncate the date to whole seconds.
legalHoldKey := strings.ToLower(xhttp.AmzObjectLockLegalHold)
forwardedLegalHold := srcInfo.UserDefined[legalHoldKey]
forwardedMeta := srcInfo.UserDefined
if forwardedLegalHold != "" {
forwardedMeta = cloneMSS(srcInfo.UserDefined)
delete(forwardedMeta, legalHoldKey)
}
opts := miniogo.PutObjectOptions{
UserMetadata: srcInfo.UserDefined,
UserMetadata: forwardedMeta,
ServerSideEncryption: dstOpts.ServerSideEncryption,
UserTags: tag.ToMap(),
}
if forwardedLegalHold != "" {
opts.LegalHold = miniogo.LegalHoldStatus(forwardedLegalHold)
}
// The destination must carry the same checksum the local path would
// produce; the federated path has the remote compute, validate, persist
// and return it. Without this the federated copy silently returns an
@@ -1996,7 +2021,10 @@ func (api objectAPIHandlers) CopyObjectHandler(w http.ResponseWriter, r *http.Re
writeErrorResponse(ctx, w, toAPIError(ctx, rerr), r.URL)
return
}
objInfo.UserDefined = cloneMSS(opts.UserMetadata)
// Built from the resolved values rather than the forwarding map: the
// legal hold was moved onto the typed option above, so opts.UserMetadata
// no longer carries it and the response and event would under-report.
objInfo.UserDefined = cloneMSS(srcInfo.UserDefined)
// A forwarded checksum header is a request detail, not object metadata.
if checksumHeaderValue != "" {
delete(objInfo.UserDefined, wantChecksumType.Key())