mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-03 14:47:04 +00:00
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:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user