fix(s3): allow anonymous unsigned-streaming PutObject (#9727)

Modern botocore attaches a CRC32 trailer to plain PutObject, turning the
payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous upload then
carries that header but no Authorization, so it was classified as
authTypeStreamingUnsigned and sent straight to SigV4 verification, which
rejected it as AccessDenied while explicit credentials kept working.

Fall back to the anonymous identity when an unsigned-streaming request
carries no signature, mirroring the plain anonymous path. The request
stays classified as unsigned-streaming so the chunked body is still
decoded.
This commit is contained in:
Chris Lu
2026-05-28 17:00:41 -07:00
committed by GitHub
parent f5b833ab6a
commit 685571d93f
2 changed files with 66 additions and 3 deletions
+15 -3
View File
@@ -1388,9 +1388,21 @@ func (iam *IdentityAccessManagement) authenticateRequestInternal(r *http.Request
identity, s3Err = iam.reqSignatureV4Verify(r)
amzAuthType = "SigV4"
case authTypeStreamingUnsigned:
glog.V(4).Infof("unsigned streaming upload")
identity, s3Err = iam.reqSignatureV4Verify(r)
amzAuthType = "SigV4"
// An unsigned-streaming PUT may still be SigV4-signed (header/presigned) or
// fully anonymous; modern botocore adds a CRC32 trailer to plain PUTs, so an
// anonymous upload also lands here. Verify a signature only when one is present.
if isRequestSignatureV4(r) || isRequestPresignedSignatureV4(r) {
glog.V(4).Infof("unsigned streaming upload, signed request")
identity, s3Err = iam.reqSignatureV4Verify(r)
amzAuthType = "SigV4"
} else {
glog.V(4).Infof("unsigned streaming upload, anonymous request")
amzAuthType = "Anonymous"
if identity, found = iam.LookupAnonymous(); !found {
r.Header.Set(s3_constants.AmzAuthType, amzAuthType)
return identity, s3err.ErrAccessDenied, reqAuthType
}
}
case authTypeJWT:
glog.V(4).Infof("jwt auth type detected, iamIntegration != nil? %t", iam.iamIntegration != nil)
r.Header.Set(s3_constants.AmzAuthType, "Jwt")
+51
View File
@@ -29,6 +29,11 @@ func signRawHTTPRequest(ctx context.Context, req *http.Request, accessKey, secre
}
func TestReproIssue7912(t *testing.T) {
// This test asserts behavior for a config with no anonymous identity; reset
// the shared in-memory store so a leaked anonymous identity from another test
// does not satisfy the unsigned-streaming auth path.
resetMemoryStore()
// Create a temporary s3.json
configContent := `{
"identities": [
@@ -198,6 +203,52 @@ func TestReproIssue7912(t *testing.T) {
})
}
// TestAnonymousStreamingUnsignedUpload is a regression test for issue #9725.
// Modern botocore/aiobotocore attaches a CRC32 trailer to plain PutObject calls,
// turning the payload into STREAMING-UNSIGNED-PAYLOAD-TRAILER. An anonymous
// (anon=True) upload then carries that header but no Authorization, which used to
// be routed straight to SigV4 verification and rejected as AccessDenied. It must
// instead fall back to the configured anonymous identity, just like a plain
// anonymous PUT does.
func TestAnonymousStreamingUnsignedUpload(t *testing.T) {
// This test loads an anonymous identity into the shared in-memory credential
// store; reset before and after so it neither inherits nor leaks state.
resetMemoryStore()
defer resetMemoryStore()
configContent := `{
"identities": [
{
"name": "anonymous",
"actions": ["Read", "Write", "List"]
}
]
}`
tmpFile, err := os.CreateTemp("", "s3-config-*.json")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
_, err = tmpFile.Write([]byte(configContent))
require.NoError(t, err)
require.NoError(t, tmpFile.Close())
iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{Config: tmpFile.Name()}, nil, "memory")
require.True(t, iam.isEnabled(), "Auth should be enabled")
r := httptest.NewRequest(http.MethodPut, "http://localhost:8333/somebucket/someobject", nil)
r.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
r.Header.Set("x-amz-trailer", "x-amz-checksum-crc32")
// No Authorization header: anonymous upload with a checksum trailer.
// The request must stay classified as unsigned-streaming so getRequestDataReader
// still decodes the chunked body; only the auth resolution falls back to anonymous.
assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r))
identity, errCode := iam.authRequest(r, s3_constants.ACTION_WRITE)
assert.Equal(t, s3err.ErrNone, errCode, "anonymous unsigned-streaming PUT should resolve to the anonymous identity")
require.NotNil(t, identity)
assert.Equal(t, s3_constants.AccountAnonymousId, identity.Name)
}
// TestExternalUrlSignatureVerification tests that S3 signature verification works
// correctly when s3.externalUrl is configured. It uses the real AWS SDK v2 signer
// to prove correctness against actual S3 clients behind a reverse proxy.