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.
This commit is contained in:
Chris Lu
2026-05-28 18:10:24 -07:00
committed by GitHub
parent 2f0643e5b1
commit 16717b0bf4
4 changed files with 82 additions and 7 deletions
+14 -5
View File
@@ -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 {
@@ -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))
}
+4
View File
@@ -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
}
+4 -2
View File
@@ -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)