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

@@ -223,6 +223,51 @@ func TestAnonymousObjectACLVersionAndDirectory(t *testing.T) {
require.Equal(t, 403, rr.Code)
}
func TestSignedReadOfVersionFileKey(t *testing.T) {
s3a, f := newAnonymousReadTestServer(t)
config, _ := s3a.getBucketConfig("b")
config.Versioning = "Enabled"
version := anonymousReadEntry(nil)
version.Name = "v_v1"
version.Extended[s3_constants.ExtVersionIdKey] = []byte("v1")
f.entries["/buckets/b/o.versions/v_v1"] = version
f.entries["/buckets/b/u.versions/v_literal"] = anonymousReadEntry(nil)
identity := &Identity{Name: "reader", Actions: []Action{s3_constants.ACTION_READ}}
serve := func(method, object, query string, headers map[string]string) int {
r := httptest.NewRequest(method, "/b/"+object+"?"+query, nil)
r = mux.SetURLVars(r, map[string]string{"bucket": "b", "object": object})
r = r.WithContext(s3_constants.SetIdentityInContext(r.Context(), identity))
for name, value := range headers {
r.Header.Set(name, value)
}
handler := s3a.GetObjectHandler
if method == http.MethodHead {
handler = s3a.HeadObjectHandler
}
rr := httptest.NewRecorder()
handler(rr, r)
return rr.Code
}
for _, method := range []string{http.MethodGet, http.MethodHead} {
require.Equal(t, 200, serve(method, "o", "versionId=v1", nil))
require.Equal(t, 403, serve(method, "o.versions/v_v1", "", nil))
require.NotEqual(t, 200, serve(method, "o.versions/v_v1/", "", nil))
require.Equal(t, 403, serve(method, "o.versions/v_v1", "", map[string]string{"If-None-Match": "*"}))
require.Equal(t, 200, serve(method, "u.versions/v_literal", "", nil))
}
r := httptest.NewRequest(http.MethodPut, "/b/dst", nil)
r = r.WithContext(s3_constants.SetIdentityInContext(r.Context(), identity))
require.Equal(t, s3err.ErrAccessDenied, s3a.recheckCopySourcePolicy(r, "b", "o.versions/v_v1", "", version.Extended, "CopyObjectHandler"))
require.Equal(t, s3err.ErrNone, s3a.recheckCopySourcePolicy(r, "b", "o", "v1", version.Extended, "CopyObjectHandler"))
for _, action := range []string{s3_constants.ACTION_GET_OBJECT_RETENTION, s3_constants.ACTION_PUT_OBJECT_LEGAL_HOLD, string(s3_constants.ACTION_WRITE)} {
require.Equal(t, s3err.ErrAccessDenied, s3a.recheckTagConditionedPolicyWithLookup(r, "b", "o.versions/v_v1", "", action, "t"))
require.Equal(t, s3err.ErrNone, s3a.recheckTagConditionedPolicyWithLookup(r, "b", "o", "v1", action, "t"))
require.Equal(t, s3err.ErrNone, s3a.recheckTagConditionedPolicyWithLookup(r, "b", "u.versions/v_literal", "", action, "t"))
}
require.Equal(t, s3err.ErrAccessDenied, s3a.deleteDirectoryMarker(r, "b", "o.versions/v_v1/"))
require.Contains(t, f.entries, "/buckets/b/o.versions/v_v1")
}
func TestAnonymousObjectACLDoesNotReplaceAuthentication(t *testing.T) {
s3a, f := newAnonymousReadTestServer(t)
f.entries["/buckets/b/o"] = anonymousReadEntry(anonymousReadGrant("READ"))