From 16717b0bf46083838c5e71bf56aa0b5b4cf85604 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 28 May 2026 18:10:24 -0700 Subject: [PATCH] fix(s3): authenticate JWT unsigned-streaming uploads (#9729) A bearer-token client whose SDK appends a CRC32 trailer sends an unsigned-streaming PUT (STREAMING-UNSIGNED-PAYLOAD-TRAILER) with no SigV4 signature, so getRequestAuthType classifies it as authTypeStreamingUnsigned. The auth dispatch ignored the bearer token and fell back to anonymous, and newChunkedReader tried to verify the bearer token as a SigV4 seed signature and failed, so the body could not be decoded either. Dispatch the streaming-unsigned auth on whatever credential is present (SigV4 / JWT / anonymous), and skip the SigV4 seed-signature recompute for JWT requests in the chunked reader. --- weed/s3api/auth_credentials.go | 19 ++++-- .../s3api/auth_jwt_streaming_unsigned_test.go | 60 +++++++++++++++++++ weed/s3api/auth_signature_v4_sts_test.go | 4 ++ weed/s3api/chunked_reader_v4.go | 6 +- 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 weed/s3api/auth_jwt_streaming_unsigned_test.go diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index a57a43672..930c53926 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -1388,14 +1388,23 @@ func (iam *IdentityAccessManagement) authenticateRequestInternal(r *http.Request identity, s3Err = iam.reqSignatureV4Verify(r) amzAuthType = "SigV4" case authTypeStreamingUnsigned: - // 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) { + // STREAMING-UNSIGNED-PAYLOAD-TRAILER only describes the body encoding; the + // request may still be SigV4-signed (header/presigned), JWT-bearer, or fully + // anonymous. Modern botocore adds a CRC32 trailer to plain PUTs, so an + // anonymous upload also lands here. Dispatch on whatever credential is present. + switch { + case isRequestSignatureV4(r) || isRequestPresignedSignatureV4(r): glog.V(4).Infof("unsigned streaming upload, signed request") identity, s3Err = iam.reqSignatureV4Verify(r) amzAuthType = "SigV4" - } else { + case isRequestJWT(r): + glog.V(4).Infof("unsigned streaming upload, jwt request") + if iam.iamIntegration == nil { + return identity, s3err.ErrNotImplemented, reqAuthType + } + identity, s3Err = iam.authenticateJWTWithIAM(r) + amzAuthType = "Jwt" + default: glog.V(4).Infof("unsigned streaming upload, anonymous request") amzAuthType = "Anonymous" if identity, found = iam.LookupAnonymous(); !found { diff --git a/weed/s3api/auth_jwt_streaming_unsigned_test.go b/weed/s3api/auth_jwt_streaming_unsigned_test.go new file mode 100644 index 000000000..86a794d67 --- /dev/null +++ b/weed/s3api/auth_jwt_streaming_unsigned_test.go @@ -0,0 +1,60 @@ +package s3api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestJWTStreamingUnsignedAuth covers the auth half of the JWT + unsigned-streaming +// gap. A bearer-token client whose SDK appends a CRC32 trailer sends +// x-amz-content-sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER with no SigV4 signature, +// so the request is classified authTypeStreamingUnsigned. It must authenticate via +// the JWT integration rather than silently falling back to the anonymous identity. +func TestJWTStreamingUnsignedAuth(t *testing.T) { + resetMemoryStore() + defer resetMemoryStore() + + iam := NewIdentityAccessManagementWithStore(&S3ApiServerOption{}, nil, "memory") + iam.iamIntegration = &MockIAMIntegration{ + authenticateJWTFunc: func(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) { + return &IAMIdentity{Name: "jwt-user", Account: &AccountAdmin}, s3err.ErrNone + }, + } + iam.isAuthEnabled = true + + 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") + r.Header.Set("Authorization", "Bearer test.jwt.token") + + // Stays classified as unsigned-streaming so getRequestDataReader still decodes + // the chunked body... + assert.Equal(t, authTypeStreamingUnsigned, getRequestAuthType(r)) + + // ...but authentication resolves to the JWT identity, not anonymous. + identity, errCode := iam.AuthenticateRequest(r) + assert.Equal(t, s3err.ErrNone, errCode, "JWT unsigned-streaming PUT must authenticate via the IAM integration") + require.NotNil(t, identity) + assert.Equal(t, "jwt-user", identity.Name) +} + +// TestJWTStreamingUnsignedChunkedReader covers the body-decode half of the gap. +// newChunkedReader must not try to verify a bearer token as a SigV4 seed signature +// for an unsigned-streaming upload. It used to compute the seed signature whenever +// any Authorization header was present, so a JWT request failed to decode at all. +func TestJWTStreamingUnsignedChunkedReader(t *testing.T) { + iam := setupIam() + + req, err := NewRequestStreamingUnsignedPayloadTrailer(true) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer test.jwt.token") + + runWithRequest(iam, req, t, strings.Repeat("a", 17408)) +} diff --git a/weed/s3api/auth_signature_v4_sts_test.go b/weed/s3api/auth_signature_v4_sts_test.go index b0dd21108..1e372b199 100644 --- a/weed/s3api/auth_signature_v4_sts_test.go +++ b/weed/s3api/auth_signature_v4_sts_test.go @@ -16,6 +16,7 @@ import ( // MockIAMIntegration is a mock implementation of IAM integration for testing type MockIAMIntegration struct { + authenticateJWTFunc func(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) authorizeFunc func(ctx context.Context, identity *IAMIdentity, action Action, bucket, object string, r *http.Request) s3err.ErrorCode validateTrustPolicyFunc func(ctx context.Context, roleArn, principalArn string) error authCalled bool @@ -30,6 +31,9 @@ func (m *MockIAMIntegration) AuthorizeAction(ctx context.Context, identity *IAMI } func (m *MockIAMIntegration) AuthenticateJWT(ctx context.Context, r *http.Request) (*IAMIdentity, s3err.ErrorCode) { + if m.authenticateJWTFunc != nil { + return m.authenticateJWTFunc(ctx, r) + } return nil, s3err.ErrNotImplemented } diff --git a/weed/s3api/chunked_reader_v4.go b/weed/s3api/chunked_reader_v4.go index ca58ecec0..0d8677b20 100644 --- a/weed/s3api/chunked_reader_v4.go +++ b/weed/s3api/chunked_reader_v4.go @@ -79,7 +79,6 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea glog.V(3).Infof("creating a new newSignV4ChunkedReader") contentSha256Header := req.Header.Get("X-Amz-Content-Sha256") - authorizationHeader := req.Header.Get("Authorization") var credential *Credential var seedSignature, region, service string @@ -96,7 +95,10 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea } case streamingUnsignedPayload: glog.V(3).Infof("streaming unsigned payload") - if authorizationHeader != "" { + // The seed signature is a SigV4 concept; recompute it only for SigV4-signed + // requests. JWT-bearer, presigned, and anonymous unsigned-streaming uploads + // carry no header seed, and verifyV4Signature would fail parsing them. + if isRequestSignatureV4(req) { // We do not need to pass the seed signature to the Reader as each chunk is not signed, // but we do compute it to verify the caller has the correct permissions. _, _, _, _, _, errCode = iam.calculateSeedSignature(req)