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>
This commit is contained in:
Chris LuandDevin committed 2026-10-06 09:16:37 -07:00
1 parent e068bd2167
commit c05a296ba1
4 files changed
+74 -13

No files matched your search

+21
View File
@@ -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
+35 -6
View File
@@ -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
}
+1 -2
View File
@@ -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)
+17 -5
View File
@@ -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
}