From 685571d93f17f95dcfb27eb28d7f5e53565c4402 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 28 May 2026 17:00:41 -0700 Subject: [PATCH] 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. --- weed/s3api/auth_credentials.go | 18 +++++++++-- weed/s3api/auth_security_test.go | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 7d714a765..a57a43672 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -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") diff --git a/weed/s3api/auth_security_test.go b/weed/s3api/auth_security_test.go index e82a4f545..b6e31089d 100644 --- a/weed/s3api/auth_security_test.go +++ b/weed/s3api/auth_security_test.go @@ -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.