You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix: S3 compatibility gaps found by the boto3 and mc smoke tests - #14
A smoke test of everyday client workflows against the 0.1.2 build (boto3, the MinIO client mc, presigned URLs) found a set of behaviours that differ from S3 and break real tools. This PR fixes all of them and adds the smoke tests under tools/ so they can be rerun against any packaged install before a release.
Listings and versions
ListObjects/V2 hide keys whose latest version is a delete marker.
ListObjectVersions emits <VersionId>null</VersionId> for unversioned objects. boto3 raised KeyError and mc rm --versions / aws s3 rb --force could not empty a bucket. null is accepted wherever a version id is.
DeleteObjects honours <VersionId> per entry instead of creating a fresh delete marker for every key, and reports VersionId / DeleteMarker / DeleteMarkerVersionId. mc rm --versions, mc undo and aws s3 rb --force all use this path.
DELETE ?versionId= echoes x-amz-version-id and x-amz-delete-marker.
ListObjectsV2 includes <Owner> only with fetch-owner=true; v1 always.
A missing version is NoSuchVersion, not NoSuchKey.
Suspended versioning and payload housekeeping
PUT on a suspended bucket keeps the real versions and replaces only the null version; DELETE inserts a null delete marker. Previously the whole history was dropped.
Overwriting an object in an unversioned bucket (or replacing the null version) now deletes the old payload file. Every overwrite used to leak one blob, and a leaked blob made the bucket permanently undeletable. Bucket emptiness is now judged by object records, not by stray files in data/.
Copy and multipart
x-amz-copy-source accepts ?versionId=; copying a delete marker by id is InvalidRequest.
x-amz-metadata-directive / x-amz-tagging-directive: REPLACE are honoured; a self-copy without REPLACE is InvalidRequest.
UploadPartCopy honours x-amz-copy-source-range (strictly inside the source, as S3 does), returns <CopyPartResult> and x-amz-copy-source-version-id, and streams the range.
CompleteMultipartUpload validates part numbers, ETags (InvalidPart) and non-last part sizes (EntityTooSmall) before touching the upload, so a rejected completion can be retried or aborted. It used to delete the upload record first and ignore client ETags.
Requests and responses
Keys with a trailing slash (folder/) keep it; the router silently dropped it.
GetObject applies the response-* header overrides, replacing the stored header.
412 and 416 carry an XML error body; 416 sets Content-Range: bytes */N.
GetBucketTagging on an untagged bucket is 404 NoSuchTagSet.
Authentication
Rejections carry the S3 reason: InvalidAccessKeyId (unknown or disabled key), SignatureDoesNotMatch, AccessDenied (anonymous or expired). RequestAuthenticator gains authenticateOrExplain(); the filter renders the failure.
SigV2 presigned URLs (AWSAccessKeyId / Expires / Signature) work when allow_sigv2 is on.
Tools
tools/smoke-test.py: boto3, 210 checks across buckets, objects, listings, copy, tagging, delete, multipart, checksums, presigned URLs, versioning, auth and concurrency. Env-configured, cleans up after itself, non-zero exit on any failure.
./gradlew build: 264 tests green (new: ClientCompatConformanceTest with 19 cases, ObjectPublisherTest, RequestAuthenticatorFailureReasonTest, S3XmlTest, MultipartServiceEtagTest, store tests)
tools/smoke-test.py against the install tree running with the packaged sample config and fsync_on_write: true: 210 pass, 0 fail, 1 skipped (CRC32C needs botocore[crt])
tools/smoke-test-mc.sh against the same server: 55 pass, 0 fail; no buckets or payload files left behind
CI
Not a release; this should go into 0.1.3 together with #13.
A smoke test of common client workflows against the 0.1.2 build (boto3,
the MinIO client and presigned URLs) turned up a set of behaviours that
differ from S3 and break real tools. This fixes all of them and adds the
smoke tests under tools/ so they can be rerun against a packaged install.
Listings and versions
- ListObjects/V2 no longer show keys whose latest version is a delete
marker.
- ListObjectVersions emits <VersionId>null</VersionId> for unversioned
objects (boto3 raised KeyError, `mc rm --versions` and `aws s3 rb
--force` could not empty a bucket); "null" is accepted wherever a
version id is, including DELETE, HEAD, GET and copy sources.
- DeleteObjects honours <VersionId> per entry instead of creating a new
delete marker for every key, and reports VersionId / DeleteMarker /
DeleteMarkerVersionId in the result. `mc rm --versions`, `mc undo` and
`aws s3 rb --force` all go through this path.
- DELETE ?versionId= echoes x-amz-version-id and x-amz-delete-marker.
- ListObjectsV2 includes <Owner> only with fetch-owner=true; v1 always.
- A missing version is NoSuchVersion rather than NoSuchKey.
Suspended versioning and payload housekeeping
- A PUT on a suspended bucket keeps the real versions in history and
replaces only the null version; a DELETE inserts a null delete marker.
Previously the whole history was dropped.
- Overwriting an object in an unversioned bucket, or replacing the null
version, now removes the old payload file. Every overwrite used to leak
one blob, and a leaked blob made the bucket undeletable: bucket
emptiness is now judged by object records, not stray data files.
Copy and multipart
- x-amz-copy-source accepts ?versionId=; copying a delete marker by id is
InvalidRequest.
- x-amz-metadata-directive and x-amz-tagging-directive REPLACE are
honoured; a copy onto itself without REPLACE is InvalidRequest.
- UploadPartCopy honours x-amz-copy-source-range (strictly inside the
source, as S3 does), returns <CopyPartResult> and
x-amz-copy-source-version-id, and streams the range instead of
buffering the whole source.
- CompleteMultipartUpload validates part numbers, ETags (InvalidPart) and
the minimum size of non-last parts (EntityTooSmall) before it touches
the upload, so a rejected completion can be retried or aborted. It used
to delete the upload record first and ignore client ETags.
Requests and responses
- Keys with a trailing slash ("folder/") keep it; the router dropped it.
- GetObject applies response-content-type, -language, -disposition,
-encoding, response-cache-control and response-expires, replacing the
stored header rather than appending to it.
- 412 and 416 carry an XML error body; 416 sets Content-Range: bytes */N.
- GetBucketTagging on an untagged bucket is 404 NoSuchTagSet.
Authentication
- Rejections name the reason the way S3 does: InvalidAccessKeyId for an
unknown or disabled key, SignatureDoesNotMatch for a bad signature,
AccessDenied for anonymous or expired requests. The authenticator gains
authenticateOrExplain(); the filter renders its AuthFailure.
- SigV2 presigned URLs (AWSAccessKeyId / Expires / Signature) are
accepted when allow_sigv2 is on.
Tests: unit tests for the store, XML, ETag matching, auth reasons and
SigV2 presigning, plus a ClientCompatConformanceTest pinning each client
visible behaviour through the AWS SDK. Full build: 264 tests green.
Tools: tools/smoke-test.py (boto3, 210 checks) and tools/smoke-test-mc.sh
(mc, 55 steps), documented in tools/README.md. Both pass against this
build running from the install tree with the sample configuration and
fsync_on_write: true.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
SigV2 only signs the query parameters in its CanonicalizedResource set,
which lacked the GetObject response-* overrides introduced in the previous
commit. Anyone holding a SigV2 presigned link could therefore change the
Content-Type or Content-Disposition it serves without breaking the
signature. The six override parameters are now part of the resource, as
AWS specifies, with a test for both the tampered and the properly signed
case.
CodeQL on the pull request flagged two more things: header values echoed
from the request (x-amz-version-id on DELETE and the response-* overrides)
are now stripped of CR/LF before use, and the MD5 helper in
tools/smoke-test.py declares usedforsecurity=False, since it only
recomputes S3 ETags.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
The SigV2 CanonicalizedResource matched raw query keys against the signed
set while the controller reads percent-decoded parameters, so a key such
as response%2Dcontent-type reached GetObject without being covered by the
signature, and a repeated parameter let the signer and the consumer pick
different copies. Keys and values are now decoded before matching and
signing (as the SDKs do), and a request that repeats a signed parameter
or carries malformed encoding is rejected.
GetObject also refuses response-* overrides on anonymous requests with
InvalidRequest, as S3 does, so a public link cannot be given an arbitrary
Content-Type or Content-Disposition by whoever shares it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical defects can bypass retention and legal holds, leave committed objects without payloads, and allow tampering with SigV2 response overrides.
This PR improves Leonardo’s S3 compatibility for everyday boto3, MinIO mc, and presigned-URL workflows. It also adds reusable smoke tests for validating packaged installations before release.
Changes:
Corrects listings, version-aware deletion, suspended versioning, and payload housekeeping.
Extends copy and multipart behavior, response headers, and authentication error reporting.
Adds focused regression tests and documented boto3 and mc smoke suites.
CodeQL does not recognise a regex replaceAll as a response-splitting
sanitizer; two literal replace calls do the same job and are.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
Netty's query parser treats ';' as a pair separator, so a parameter hidden
after a ';' inside another value reached the controller without being part
of the signed CanonicalizedResource. The resource builder now splits the
raw query the same way.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical defects can delete committed or in-flight payloads, bypass WORM protection, and allow request changes without invalidating SigV2 signatures.
CodeQL's response-splitting query does not treat String.replace as a
barrier, so the sanitizer now rebuilds the value without CR and LF; taint
does not survive the primitive char copy, and the behaviour is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Lucas Holt <luke@foolishgames.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Unresolved critical flaws allow unauthorized response overrides on signed requests and can delete committed object payloads or cause concurrent reads to fail during overwrites.
A header-signed SigV4 request can gain a response override without invalidating its signature: x=1%3Bresponse-content-type%3Dtext%2Fhtml and x=1;response-content-type=text/html produce the same canonical query, but Netty treats only the latter as a separate override. Applying that override here lets a modified request change the returned Content-Type without the signer's authorization. Align SigV4 canonicalization with the API's query parsing, or reject raw semicolon separators before authentication, and add a regression test for this encoded-to-literal rewrite.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Unresolved critical issues can delete successfully published payloads, race with readers and in-flight writes, and copy historical versions with incorrect metadata or checksums.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A smoke test of everyday client workflows against the 0.1.2 build (boto3, the MinIO client
mc, presigned URLs) found a set of behaviours that differ from S3 and break real tools. This PR fixes all of them and adds the smoke tests undertools/so they can be rerun against any packaged install before a release.Listings and versions
<VersionId>null</VersionId>for unversioned objects. boto3 raisedKeyErrorandmc rm --versions/aws s3 rb --forcecould not empty a bucket.nullis accepted wherever a version id is.<VersionId>per entry instead of creating a fresh delete marker for every key, and reportsVersionId/DeleteMarker/DeleteMarkerVersionId.mc rm --versions,mc undoandaws s3 rb --forceall use this path.DELETE ?versionId=echoesx-amz-version-idandx-amz-delete-marker.<Owner>only withfetch-owner=true; v1 always.NoSuchVersion, notNoSuchKey.Suspended versioning and payload housekeeping
data/.Copy and multipart
x-amz-copy-sourceaccepts?versionId=; copying a delete marker by id isInvalidRequest.x-amz-metadata-directive/x-amz-tagging-directive: REPLACEare honoured; a self-copy without REPLACE isInvalidRequest.x-amz-copy-source-range(strictly inside the source, as S3 does), returns<CopyPartResult>andx-amz-copy-source-version-id, and streams the range.InvalidPart) and non-last part sizes (EntityTooSmall) before touching the upload, so a rejected completion can be retried or aborted. It used to delete the upload record first and ignore client ETags.Requests and responses
folder/) keep it; the router silently dropped it.response-*header overrides, replacing the stored header.Content-Range: bytes */N.NoSuchTagSet.Authentication
InvalidAccessKeyId(unknown or disabled key),SignatureDoesNotMatch,AccessDenied(anonymous or expired).RequestAuthenticatorgainsauthenticateOrExplain(); the filter renders the failure.AWSAccessKeyId/Expires/Signature) work whenallow_sigv2is on.Tools
tools/smoke-test.py: boto3, 210 checks across buckets, objects, listings, copy, tagging, delete, multipart, checksums, presigned URLs, versioning, auth and concurrency. Env-configured, cleans up after itself, non-zero exit on any failure.tools/smoke-test-mc.sh: 55mcsteps (cp, mirror, tag, versioning,rm --versions,undo,rb --force).tools/README.md.Test plan
./gradlew build: 264 tests green (new:ClientCompatConformanceTestwith 19 cases,ObjectPublisherTest,RequestAuthenticatorFailureReasonTest,S3XmlTest,MultipartServiceEtagTest, store tests)tools/smoke-test.pyagainst the install tree running with the packaged sample config andfsync_on_write: true: 210 pass, 0 fail, 1 skipped (CRC32C needsbotocore[crt])tools/smoke-test-mc.shagainst the same server: 55 pass, 0 fail; no buckets or payload files left behindNot a release; this should go into 0.1.3 together with #13.
🤖 Generated with Claude Code