From c05a296ba14120536199a833d43b5d6a29dfbd3f Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 6 Oct 2026 08:53:10 -0700 Subject: [PATCH] s3api: answer IAM/STS failures in the query-protocol envelope Embedded IAM and STS routes now report authentication, form-parse and authorization failures with the IAM ErrorResponse body instead of the S3 Error envelope, so IAM SDK clients can parse them. Requests signed for s3 keep the S3 envelope, keyed off the credential scope. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- weed/s3api/auth_signature_v4.go | 21 ++++++++++++++++ weed/s3api/s3api_embedded_iam.go | 41 ++++++++++++++++++++++++++----- weed/s3api/s3api_iam_oidc_test.go | 3 +-- weed/s3api/s3api_server.go | 22 +++++++++++++---- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/weed/s3api/auth_signature_v4.go b/weed/s3api/auth_signature_v4.go index 1cd3268edd..7dc76ada1c 100644 --- a/weed/s3api/auth_signature_v4.go +++ b/weed/s3api/auth_signature_v4.go @@ -126,6 +126,27 @@ func normalizePayloadHash(payloadHashValue string) string { return payloadHashValue } +// requestSigningService extracts the service name from the SigV4 credential +// scope (…/region/service/aws4_request) so a failed request can be answered in +// that service's error envelope. +func requestSigningService(r *http.Request) string { + credential := r.URL.Query().Get("X-Amz-Credential") + if credential == "" { + v4Auth := strings.Replace(r.Header.Get("Authorization"), " ", "", -1) + v4Auth = strings.TrimPrefix(v4Auth, signV4Algorithm) + for _, field := range strings.Split(v4Auth, ",") { + if cred, ok := strings.CutPrefix(field, "Credential="); ok { + credential = cred + break + } + } + } + if parts := strings.Split(credential, "/"); len(parts) == 5 { + return parts[3] + } + return "" +} + // signValues data type represents structured form of AWS Signature V4 header. type signValues struct { Credential credentialHeader diff --git a/weed/s3api/s3api_embedded_iam.go b/weed/s3api/s3api_embedded_iam.go index 055754384e..bd1ea53354 100644 --- a/weed/s3api/s3api_embedded_iam.go +++ b/weed/s3api/s3api_embedded_iam.go @@ -196,10 +196,32 @@ func newIamErrorResponse(errCode string, errMsg string, requestID string) iamErr return errorResp } -func (e *EmbeddedIamApi) writeIamErrorResponse(w http.ResponseWriter, r *http.Request, reqID string, iamErr *iamError) { +// iamAuthErrorCode maps an S3-side signature failure onto the IAM +// query-protocol code IAM clients expect for the same request. +func iamAuthErrorCode(errCode s3err.ErrorCode) string { + switch errCode { + case s3err.ErrInvalidAccessKeyID: + return "InvalidClientTokenId" + case s3err.ErrSignatureDoesNotMatch: + return "SignatureDoesNotMatch" + case s3err.ErrAccessDenied: + return "AccessDenied" + case s3err.ErrExpiredToken: + return "ExpiredToken" + case s3err.ErrRequestTimeTooSkewed: + return "RequestTimeTooSkewed" + case s3err.ErrInternalError: + return iam.ErrCodeServiceFailureException + default: + return "IncompleteSignature" + } +} + +func writeIamErrorResponse(w http.ResponseWriter, r *http.Request, reqID string, iamErr *iamError) { if iamErr == nil { glog.Errorf("writeIamErrorResponse called with nil error") internalResp := newIamErrorResponse(iam.ErrCodeServiceFailureException, "Internal server error", reqID) + internalResp.Error.Type = "Receiver" s3err.WriteXMLResponse(w, r, http.StatusInternalServerError, internalResp) return } @@ -2562,15 +2584,19 @@ func (iam *IdentityAccessManagement) AuthIamManagement(f http.HandlerFunc) http. // needs to hash the body for IAM requests (service != "s3"). // The streamHashRequestBody function in auth_signature_v4.go preserves the body // after reading it, so ParseForm() will work correctly after authentication. + r, reqID := request_id.Ensure(r) identity, errCode := iam.AuthSignatureOnly(r) if errCode != s3err.ErrNone { - s3err.WriteErrorResponse(w, r, errCode) + writeIamErrorResponse(w, r, reqID, &iamError{ + Code: iamAuthErrorCode(errCode), + Error: errors.New(s3err.GetAPIError(errCode).Description), + }) return } // Now parse form to get Action and UserName (body was preserved by auth) if err := r.ParseForm(); err != nil { - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + writeIamErrorResponse(w, r, reqID, &iamError{Code: "InvalidInput", Error: err}) return } @@ -2578,7 +2604,10 @@ func (iam *IdentityAccessManagement) AuthIamManagement(f http.HandlerFunc) http. // from, so the authorized target and the acted-on target cannot differ. action := r.Form.Get("Action") if errCode := iam.AuthorizeIamAction(r, identity, action, iamTargetUserName(action, r)); errCode != s3err.ErrNone { - s3err.WriteErrorResponse(w, r, errCode) + writeIamErrorResponse(w, r, reqID, &iamError{ + Code: iamAuthErrorCode(errCode), + Error: errors.New(s3err.GetAPIError(errCode).Description), + }) return } @@ -3003,7 +3032,7 @@ func (e *EmbeddedIamApi) ExecuteAction(ctx context.Context, values url.Values, s func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) { r, reqID := request_id.Ensure(r) if err := r.ParseForm(); err != nil { - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + writeIamErrorResponse(w, r, reqID, &iamError{Code: iam.ErrCodeInvalidInputException, Error: err}) return } values := r.PostForm @@ -3019,7 +3048,7 @@ func (e *EmbeddedIamApi) DoActions(w http.ResponseWriter, r *http.Request) { response, iamErr := e.ExecuteAction(r.Context(), values, false, reqID) if iamErr != nil { - e.writeIamErrorResponse(w, r, reqID, iamErr) + writeIamErrorResponse(w, r, reqID, iamErr) return } diff --git a/weed/s3api/s3api_iam_oidc_test.go b/weed/s3api/s3api_iam_oidc_test.go index 70807ea808..0889885ce4 100644 --- a/weed/s3api/s3api_iam_oidc_test.go +++ b/weed/s3api/s3api_iam_oidc_test.go @@ -402,9 +402,8 @@ func TestUpdateThumbprintAndTags(t *testing.T) { // A refusal to change a config-file provider reaches the client as AWS sends // it (400 UnmodifiableEntity), not as an internal error clients retry. func TestUnmodifiableEntityIsAClientError(t *testing.T) { - api := NewEmbeddedIamApiForTest() rec := httptest.NewRecorder() - api.writeIamErrorResponse(rec, httptest.NewRequest(http.MethodPost, "/", nil), "req-1", + writeIamErrorResponse(rec, httptest.NewRequest(http.MethodPost, "/", nil), "req-1", oidcMutationError(fmt.Errorf("%w: arn:aws:iam:::oidc-provider/static.example", integration.ErrOIDCProviderStatic))) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index b5c9305bcd..920d8fb47d 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "math" @@ -730,10 +731,21 @@ func (s3a *S3ApiServer) handleCORSOriginValidation(w http.ResponseWriter, r *htt // UnifiedPostHandler handles authenticated POST requests to the root path // It inspects the Action parameter to dispatch to either STS or IAM handlers func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Request) { + // Failures on this route answer in the IAM/STS query-protocol envelope + // unless the request was actually signed for s3. + r, reqID := request_id.Ensure(r) + writeQueryError := func(iamCode string, s3Code s3err.ErrorCode) { + if requestSigningService(r) == "s3" { + s3err.WriteErrorResponse(w, r, s3Code) + return + } + writeIamErrorResponse(w, r, reqID, &iamError{Code: iamCode, Error: errors.New(s3err.GetAPIError(s3Code).Description)}) + } + // 1. Authenticate (preserves body) identity, errCode := s3a.iam.AuthSignatureOnly(r) if errCode != s3err.ErrNone { - s3err.WriteErrorResponse(w, r, errCode) + writeQueryError(iamAuthErrorCode(errCode), errCode) return } @@ -745,7 +757,7 @@ func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Reques bodyBytes, err = readRequestBody(r, iamRequestBodyLimit) if err != nil { glog.Errorf("failed to read request body: %v", err) - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + writeQueryError("InvalidInput", s3err.ErrInvalidRequest) return } r.Body.Close() @@ -754,7 +766,7 @@ func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Reques } if err := r.ParseForm(); err != nil { - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + writeQueryError("InvalidInput", s3err.ErrInvalidRequest) return } @@ -781,7 +793,7 @@ func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Reques // IAM // IAM API requests must be authenticated - reject nil identity if identity == nil { - s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + writeQueryError("AccessDenied", s3err.ErrAccessDenied) return } @@ -792,7 +804,7 @@ func (s3a *S3ApiServer) UnifiedPostHandler(w http.ResponseWriter, r *http.Reques // UserName comes from the body only, the same place DoActions reads it // from, so the authorized target and the acted-on target cannot differ. if s3a.iam.AuthorizeIamAction(r, identity, action, iamTargetUserName(action, r)) != s3err.ErrNone { - s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + writeQueryError("AccessDenied", s3err.ErrAccessDenied) return }