s3api: deny signed reads of a version file through its physical path (#12574)

* s3api: deny signed reads that name a version file by its physical path

A stored version lives at <key>.versions/v_<id>, and that path is also a
valid object key. Anonymous reads already refuse it; signed GET, HEAD,
copy sources and object subresources did not, so a caller without
s3:GetObjectVersion, or under a Deny on the logical key, could read any
version through it. Apply the same check in the shared entry recheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: recheck a version file key before answering a conditional read

A conditional GET or HEAD answered 304 or 412 with the ETag before the
handler's entry recheck, so the physical version path still confirmed a
version's ETag to signed callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: look up version file keys in the lookup-based policy recheck

Retention, legal hold, tagging writes and deletes only fetched the entry
for the recheck when the bucket had a policy, so without one they still
acted on a version file named by its physical path. Fetch it whenever
the key has the version file shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: refuse a directory-marker delete that names a version file

A trailing slash kept "<key>.versions/v_<id>/" out of the version file
shape check, and the lookup recheck misses the entry for that form, so a
DELETE reached deleteDirectoryMarker and removed a chunkless version or
delete marker without s3:DeleteObjectVersion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-12 08:21:31 -08:00
1 parent 317d907334
commit a6a3bdef8d
5 files changed
+69 -10

No files matched your search

+15 -6
View File
@@ -52,16 +52,25 @@ func (s3a *S3ApiServer) deferAnonymousObjectRead(r *http.Request, action Action,
return r.WithContext(context.WithValue(ctx, anonymousObjectReadContextKey{}, true)), true
}
// isVersionFileKey reports whether object names a stored version's physical
// filer path rather than a logical key: accepting it would bypass
// GetObjectVersion policies and key-scoped denies on the logical key. Ordinary
// user keys containing ".versions" without version metadata are unaffected.
func (s3a *S3ApiServer) isVersionFileKey(object string, extended map[string][]byte) bool {
version := string(extended[s3_constants.ExtVersionIdKey])
return version != "" && s3a.hasVersionFileShape(object) && path.Base(s3_constants.NormalizeObjectKey(object)) == s3a.getVersionFileName(version)
}
func (s3a *S3ApiServer) hasVersionFileShape(object string) bool {
key := strings.TrimSuffix(s3_constants.NormalizeObjectKey(object), "/")
return strings.HasSuffix(path.Dir(key), s3_constants.VersionsFolder) && strings.HasPrefix(path.Base(key), s3a.getVersionFileName(""))
}
// authorizeAnonymousObjectRead evaluates policies and ACLs against the same
// entry used for the response, including conditional and versioned reads.
// Explicit policy denies take precedence over every public-access grant.
func (s3a *S3ApiServer) authorizeAnonymousObjectRead(r *http.Request, bucket, object string, extended map[string][]byte) s3err.ErrorCode {
// A version's physical filer path is not another S3 key: accepting it would
// bypass GetObjectVersion policies on the logical key. Ordinary user keys
// containing ".versions" without internal version metadata are unaffected.
key := s3_constants.NormalizeObjectKey(object)
version := string(extended[s3_constants.ExtVersionIdKey])
if version != "" && strings.HasSuffix(path.Dir(key), s3_constants.VersionsFolder) && path.Base(key) == s3a.getVersionFileName(version) {
if s3a.isVersionFileKey(object, extended) {
return s3err.ErrAccessDenied
}
if string(extended[s3_constants.ExtDeleteMarkerKey]) == "true" {