diff --git a/builtin/credential/aws/backend.go b/builtin/credential/aws/backend.go index 1a8b3f089332..df5f7ec23325 100644 --- a/builtin/credential/aws/backend.go +++ b/builtin/credential/aws/backend.go @@ -22,6 +22,7 @@ import ( const ( amzHeaderPrefix = "X-Amz-" + amzSignedHeaders = "X-Amz-SignedHeaders" operationPrefixAWS = "aws" ) @@ -32,7 +33,8 @@ var defaultAllowedSTSRequestHeaders = []string{ "X-Amz-Date", "X-Amz-Security-Token", "X-Amz-Signature", - "X-Amz-SignedHeaders", + amzSignedHeaders, + "X-Amz-User-Agent", } func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { diff --git a/builtin/credential/aws/path_config_client.go b/builtin/credential/aws/path_config_client.go index 247d14a27120..ed6e5d89819b 100644 --- a/builtin/credential/aws/path_config_client.go +++ b/builtin/credential/aws/path_config_client.go @@ -8,6 +8,7 @@ import ( "errors" "net/http" "net/textproto" + "net/url" "strings" "github.com/aws/aws-sdk-go/aws" @@ -388,6 +389,9 @@ type clientConfig struct { func (c *clientConfig) validateAllowedSTSHeaderValues(headers http.Header) error { for k := range headers { h := textproto.CanonicalMIMEHeaderKey(k) + if h == "X-Amz-Signedheaders" { + h = amzSignedHeaders + } if strings.HasPrefix(h, amzHeaderPrefix) && !strutil.StrListContains(defaultAllowedSTSRequestHeaders, h) && !strutil.StrListContains(c.AllowedSTSHeaderValues, h) { @@ -397,6 +401,21 @@ func (c *clientConfig) validateAllowedSTSHeaderValues(headers http.Header) error return nil } +func (c *clientConfig) validateAllowedSTSQueryValues(params url.Values) error { + for k := range params { + h := textproto.CanonicalMIMEHeaderKey(k) + if h == "X-Amz-Signedheaders" { + h = amzSignedHeaders + } + if strings.HasPrefix(h, amzHeaderPrefix) && + !strutil.StrListContains(defaultAllowedSTSRequestHeaders, k) && + !strutil.StrListContains(c.AllowedSTSHeaderValues, k) { + return errors.New("invalid request query param: " + k) + } + } + return nil +} + const pathConfigClientHelpSyn = ` Configure AWS IAM credentials that are used to query instance and role details from the AWS API. ` diff --git a/builtin/credential/aws/path_login.go b/builtin/credential/aws/path_login.go index da73262670cb..68a1708ee911 100644 --- a/builtin/credential/aws/path_login.go +++ b/builtin/credential/aws/path_login.go @@ -97,7 +97,7 @@ significance.`, Type: framework.TypeString, Description: `HTTP method to use for the AWS request when auth_type is iam. This must match what has been signed in the -presigned request. Currently, POST is the only supported value`, +presigned request.`, }, "iam_request_url": { @@ -253,9 +253,8 @@ func (b *backend) pathLoginIamGetRoleNameCallerIdAndEntity(ctx context.Context, return "", nil, nil, logical.ErrorResponse("missing iam_http_request_method"), nil } - // In the future, might consider supporting GET - if method != "POST" { - return "", nil, nil, logical.ErrorResponse("invalid iam_http_request_method; currently only 'POST' is supported"), nil + if method != http.MethodGet && method != http.MethodPost { + return "", nil, nil, logical.ErrorResponse("invalid iam_http_request_method; currently only 'GET' and 'POST' are supported"), nil } rawUrlB64 := data.Get("iam_request_url").(string) @@ -270,16 +269,12 @@ func (b *backend) pathLoginIamGetRoleNameCallerIdAndEntity(ctx context.Context, if err != nil { return "", nil, nil, logical.ErrorResponse("error parsing iam_request_url"), nil } - if parsedUrl.RawQuery != "" { - // Should be no query parameters - return "", nil, nil, logical.ErrorResponse(logical.ErrInvalidRequest.Error()), nil + if err = validateLoginIamRequestUrl(method, parsedUrl); err != nil { + return "", nil, nil, logical.ErrorResponse(err.Error()), nil } - // TODO: There are two potentially valid cases we're not yet supporting that would - // necessitate this check being changed. First, if we support GET requests. - // Second if we support presigned POST requests bodyB64 := data.Get("iam_request_body").(string) - if bodyB64 == "" { - return "", nil, nil, logical.ErrorResponse("missing iam_request_body"), nil + if bodyB64 == "" && method != http.MethodGet { + return "", nil, nil, logical.ErrorResponse("missing iam_request_body which is required for POST requests"), nil } bodyRaw, err := base64.StdEncoding.DecodeString(bodyB64) if err != nil { @@ -305,7 +300,7 @@ func (b *backend) pathLoginIamGetRoleNameCallerIdAndEntity(ctx context.Context, maxRetries := awsClient.DefaultRetryerMaxNumRetries if config != nil { if config.IAMServerIdHeaderValue != "" { - err = validateVaultHeaderValue(headers, parsedUrl, config.IAMServerIdHeaderValue) + err = validateVaultHeaderValue(method, headers, parsedUrl, config.IAMServerIdHeaderValue) if err != nil { return "", nil, nil, logical.ErrorResponse(fmt.Sprintf("error validating %s header: %v", iamServerIdHeader, err)), nil } @@ -313,6 +308,11 @@ func (b *backend) pathLoginIamGetRoleNameCallerIdAndEntity(ctx context.Context, if err = config.validateAllowedSTSHeaderValues(headers); err != nil { return "", nil, nil, logical.ErrorResponse(err.Error()), nil } + if method == http.MethodGet { + if err = config.validateAllowedSTSQueryValues(parsedUrl.Query()); err != nil { + return "", nil, nil, logical.ErrorResponse(err.Error()), nil + } + } if config.STSEndpoint != "" { endpoint = config.STSEndpoint } @@ -1534,6 +1534,31 @@ func hasWildcardBind(boundIamPrincipalARNs []string) bool { return false } +// Validate that the iam_request_url passed is valid for the STS request +func validateLoginIamRequestUrl(method string, parsedUrl *url.URL) error { + switch method { + case http.MethodGet: + actions := map[string][]string(parsedUrl.Query())["Action"] + if len(actions) == 0 { + return fmt.Errorf("no action found in request") + } + if len(actions) != 1 { + return fmt.Errorf("found multiple actions") + } + if actions[0] != "GetCallerIdentity" { + return fmt.Errorf("unexpected action parameter, %s", actions[0]) + } + return nil + case http.MethodPost: + if parsedUrl.RawQuery != "" { + return logical.ErrInvalidRequest + } + return nil + default: + return fmt.Errorf("unsupported method, %s", method) + } +} + // Validate that the iam_request_body passed is valid for the STS request func validateLoginIamRequestBody(body string) error { qs, err := url.ParseQuery(body) @@ -1570,11 +1595,11 @@ func hasValuesForEc2Auth(data *framework.FieldData) (bool, bool) { } func hasValuesForIamAuth(data *framework.FieldData) (bool, bool) { - _, hasRequestMethod := data.GetOk("iam_http_request_method") + method, hasRequestMethod := data.GetOk("iam_http_request_method") _, hasRequestURL := data.GetOk("iam_request_url") _, hasRequestBody := data.GetOk("iam_request_body") _, hasRequestHeaders := data.GetOk("iam_request_headers") - return (hasRequestMethod && hasRequestURL && hasRequestBody && hasRequestHeaders), + return (hasRequestMethod && hasRequestURL && (method == http.MethodGet || hasRequestBody) && hasRequestHeaders), (hasRequestMethod || hasRequestURL || hasRequestBody || hasRequestHeaders) } @@ -1628,7 +1653,7 @@ func parseIamArn(iamArn string) (*iamEntity, error) { return &entity, nil } -func validateVaultHeaderValue(headers http.Header, _ *url.URL, requiredHeaderValue string) error { +func validateVaultHeaderValue(method string, headers http.Header, parsedUrl *url.URL, requiredHeaderValue string) error { providedValue := "" for k, v := range headers { if strings.EqualFold(iamServerIdHeader, k) { @@ -1644,25 +1669,29 @@ func validateVaultHeaderValue(headers http.Header, _ *url.URL, requiredHeaderVal if providedValue != requiredHeaderValue { return fmt.Errorf("expected %q but got %q", requiredHeaderValue, providedValue) } - - if authzHeaders, ok := headers["Authorization"]; ok { - // authzHeader looks like AWS4-HMAC-SHA256 Credential=AKI..., SignedHeaders=host;x-amz-date;x-vault-awsiam-id, Signature=... - // We need to extract out the SignedHeaders - re := regexp.MustCompile(".*SignedHeaders=([^,]+)") - authzHeader := strings.Join(authzHeaders, ",") - matches := re.FindSubmatch([]byte(authzHeader)) - if len(matches) < 1 { - return fmt.Errorf("vault header wasn't signed") - } - if len(matches) > 2 { - return fmt.Errorf("found multiple SignedHeaders components") + switch method { + case http.MethodPost: + if authzHeaders, ok := headers["Authorization"]; ok { + // authzHeader looks like AWS4-HMAC-SHA256 Credential=AKI..., SignedHeaders=host;x-amz-date;x-vault-awsiam-id, Signature=... + // We need to extract out the SignedHeaders + re := regexp.MustCompile(".*SignedHeaders=([^,]+)") + authzHeader := strings.Join(authzHeaders, ",") + matches := re.FindSubmatch([]byte(authzHeader)) + if len(matches) < 1 { + return fmt.Errorf("vault header wasn't signed") + } + if len(matches) > 2 { + return fmt.Errorf("found multiple SignedHeaders components") + } + signedHeaders := string(matches[1]) + return ensureHeaderIsSigned(signedHeaders, iamServerIdHeader) } - signedHeaders := string(matches[1]) - return ensureHeaderIsSigned(signedHeaders, iamServerIdHeader) + return fmt.Errorf("missing Authorization header") + case http.MethodGet: + return ensureHeaderIsSigned(parsedUrl.Query().Get(amzSignedHeaders), iamServerIdHeader) + default: + return fmt.Errorf("unsupported method, %s", method) } - // TODO: If we support GET requests, then we need to parse the X-Amz-SignedHeaders - // argument out of the query string and search in there for the header value - return fmt.Errorf("missing Authorization header") } func buildHttpRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) *http.Request { diff --git a/builtin/credential/aws/path_login_test.go b/builtin/credential/aws/path_login_test.go index 643d69283751..26d9b194adcd 100644 --- a/builtin/credential/aws/path_login_test.go +++ b/builtin/credential/aws/path_login_test.go @@ -126,9 +126,129 @@ func TestBackend_pathLogin_parseIamArn(t *testing.T) { } } -func TestBackend_validateVaultHeaderValue(t *testing.T) { +func TestBackend_validateVaultGetRequestValues(t *testing.T) { const canaryHeaderValue = "Vault-Server" - requestURL, err := url.Parse("https://sts.amazonaws.com/") + + getHeadersMissing := http.Header{ + "Host": []string{"Foo"}, + } + getHeadersInvalid := http.Header{ + "Host": []string{"Foo"}, + iamServerIdHeader: []string{"InvalidValue"}, + } + getHeadersValid := http.Header{ + "Host": []string{"Foo"}, + iamServerIdHeader: []string{canaryHeaderValue}, + } + getQueryValid := url.Values(map[string][]string{ + "X-Amz-Algorithm": {"AWS4-HMAC-SHA256"}, + "X-Amz-Credential": {"AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"}, + amzSignedHeaders: {"host;x-vault-aws-iam-server-id"}, + "X-Amz-Signature": {"5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, + "X-Amz-User-Agent": {"aws-sdk-go-v2/1.2.0 os/linux lang/go/1.16 md/GOOS/linux md/GOARCH/amd64"}, + "Action": {"GetCallerIdentity"}, + "Version": {"2011-06-15"}, + }) + getQueryUnsigned := url.Values(map[string][]string{ + "X-Amz-Algorithm": {"AWS4-HMAC-SHA256"}, + "X-Amz-Credential": {"AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"}, + amzSignedHeaders: {"host"}, + "X-Amz-Signature": {"5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, + "X-Amz-User-Agent": {"aws-sdk-go-v2/1.2.0 os/linux lang/go/1.16 md/GOOS/linux md/GOARCH/amd64"}, + "Action": {"GetCallerIdentity"}, + "Version": {"2011-06-15"}, + }) + getQueryNoAction := url.Values(map[string][]string{ + "X-Amz-Algorithm": {"AWS4-HMAC-SHA256"}, + "X-Amz-Credential": {"AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"}, + amzSignedHeaders: {"host;x-vault-aws-iam-server-id"}, + "X-Amz-Signature": {"5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, + "X-Amz-User-Agent": {"aws-sdk-go-v2/1.2.0 os/linux lang/go/1.16 md/GOOS/linux md/GOARCH/amd64"}, + "Version": {"2011-06-15"}, + }) + getQueryInvalidAction := url.Values(map[string][]string{ + "X-Amz-Algorithm": {"AWS4-HMAC-SHA256"}, + "X-Amz-Credential": {"AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"}, + amzSignedHeaders: {"host;x-vault-aws-iam-server-id"}, + "X-Amz-Signature": {"5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, + "X-Amz-User-Agent": {"aws-sdk-go-v2/1.2.0 os/linux lang/go/1.16 md/GOOS/linux md/GOARCH/amd64"}, + "Action": {"GetSessionToken"}, + "Version": {"2011-06-15"}, + }) + getQueryMultipleActions := url.Values(map[string][]string{ + "X-Amz-Algorithm": {"AWS4-HMAC-SHA256"}, + "X-Amz-Credential": {"AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request"}, + amzSignedHeaders: {"host;x-vault-aws-iam-server-id"}, + "X-Amz-Signature": {"5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, + "X-Amz-User-Agent": {"aws-sdk-go-v2/1.2.0 os/linux lang/go/1.16 md/GOOS/linux md/GOARCH/amd64"}, + "Action": {"GetCallerIdentity;GetSessionToken"}, + "Version": {"2011-06-15"}, + }) + validGetRequestURL, err := url.Parse("https://sts.amazonaws.com/?" + getQueryValid.Encode()) + if err != nil { + t.Fatalf("error parsing test URL: %v", err) + } + unsignedGetRequestURL, err := url.Parse("https://sts.amazonaws.com/?" + getQueryUnsigned.Encode()) + if err != nil { + t.Fatalf("error parsing test URL: %v", err) + } + noActionGetRequestURL, err := url.Parse("https://sts.amazonaws.com/?" + getQueryNoAction.Encode()) + if err != nil { + t.Fatalf("error parsing test URL: %v", err) + } + invalidActionGetRequestURL, err := url.Parse("https://sts.amazonaws.com/?" + getQueryInvalidAction.Encode()) + if err != nil { + t.Fatalf("error parsing test URL: %v", err) + } + multipleActionsGetRequestURL, err := url.Parse("https://sts.amazonaws.com/?" + getQueryMultipleActions.Encode()) + if err != nil { + t.Fatalf("error parsing test URL: %v", err) + } + + err = validateVaultHeaderValue(http.MethodGet, getHeadersMissing, validGetRequestURL, canaryHeaderValue) + if err == nil { + t.Error("validated GET request with missing Vault header") + } + + err = validateVaultHeaderValue(http.MethodGet, getHeadersInvalid, validGetRequestURL, canaryHeaderValue) + if err == nil { + t.Error("validated GET request with invalid Vault header value") + } + + err = validateVaultHeaderValue(http.MethodGet, getHeadersValid, unsignedGetRequestURL, canaryHeaderValue) + if err == nil { + t.Error("validated GET request with unsigned Vault header") + } + + err = validateLoginIamRequestUrl(http.MethodGet, noActionGetRequestURL) + if err == nil { + t.Error("validated GET request with no Action parameter") + } + + err = validateLoginIamRequestUrl(http.MethodGet, multipleActionsGetRequestURL) + if err == nil { + t.Error("validated GET request with multiple Action parameters") + } + + err = validateLoginIamRequestUrl(http.MethodGet, invalidActionGetRequestURL) + if err == nil { + t.Error("validated GET request with an invalid Action parameter") + } + + err = validateLoginIamRequestUrl(http.MethodGet, validGetRequestURL) + if err != nil { + t.Errorf("did NOT validate valid GET request: %v", err) + } + + err = validateVaultHeaderValue(http.MethodGet, getHeadersValid, validGetRequestURL, canaryHeaderValue) + if err != nil { + t.Errorf("did NOT validate valid GET request: %v", err) + } +} + +func TestBackend_validateVaultPostRequestValues(t *testing.T) { + const canaryHeaderValue = "Vault-Server" + postRequestURL, err := url.Parse("https://sts.amazonaws.com/") if err != nil { t.Fatalf("error parsing test URL: %v", err) } @@ -151,34 +271,38 @@ func TestBackend_validateVaultHeaderValue(t *testing.T) { iamServerIdHeader: []string{canaryHeaderValue}, "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, } - postHeadersSplit := http.Header{ "Host": []string{"Foo"}, iamServerIdHeader: []string{canaryHeaderValue}, "Authorization": []string{"AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/iam/aws4_request", "SignedHeaders=content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7"}, } - err = validateVaultHeaderValue(postHeadersMissing, requestURL, canaryHeaderValue) + err = validateVaultHeaderValue(http.MethodPost, postHeadersMissing, postRequestURL, canaryHeaderValue) if err == nil { t.Error("validated POST request with missing Vault header") } - err = validateVaultHeaderValue(postHeadersInvalid, requestURL, canaryHeaderValue) + err = validateVaultHeaderValue(http.MethodPost, postHeadersInvalid, postRequestURL, canaryHeaderValue) if err == nil { t.Error("validated POST request with invalid Vault header value") } - err = validateVaultHeaderValue(postHeadersUnsigned, requestURL, canaryHeaderValue) + err = validateVaultHeaderValue(http.MethodPost, postHeadersUnsigned, postRequestURL, canaryHeaderValue) if err == nil { t.Error("validated POST request with unsigned Vault header") } - err = validateVaultHeaderValue(postHeadersValid, requestURL, canaryHeaderValue) + err = validateVaultHeaderValue(http.MethodPost, postHeadersValid, postRequestURL, canaryHeaderValue) + if err != nil { + t.Errorf("did NOT validate valid POST request: %v", err) + } + + err = validateLoginIamRequestUrl(http.MethodPost, postRequestURL) if err != nil { t.Errorf("did NOT validate valid POST request: %v", err) } - err = validateVaultHeaderValue(postHeadersSplit, requestURL, canaryHeaderValue) + err = validateVaultHeaderValue(http.MethodPost, postHeadersSplit, postRequestURL, canaryHeaderValue) if err != nil { t.Errorf("did NOT validate valid POST request with split Authorization header: %v", err) } diff --git a/changelog/10961.txt b/changelog/10961.txt new file mode 100644 index 000000000000..5387a53d38ae --- /dev/null +++ b/changelog/10961.txt @@ -0,0 +1,3 @@ +```release-note:improvement +auth/aws: Added support for signed GET requests for authenticating to vault using the aws iam method. +``` diff --git a/website/content/api-docs/auth/aws.mdx b/website/content/api-docs/auth/aws.mdx index 2beb84c18bc1..12bc598d3623 100644 --- a/website/content/api-docs/auth/aws.mdx +++ b/website/content/api-docs/auth/aws.mdx @@ -1078,18 +1078,18 @@ for more information on the signature types. enabled on either the role or the role tag, the `nonce` holds no significance. This is ignored unless using the ec2 auth method. - `iam_http_request_method` `(string: )` - HTTP method used in the - signed request. Currently only POST is supported, but other methods may be - supported in the future. This is required when using the iam auth method. + signed request. This is required when using the iam auth method. - `iam_request_url` `(string: )` - Base64-encoded HTTP URL used in the signed request. Most likely just `aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=` (base64-encoding of `https://sts.amazonaws.com/`) as most requests will - probably use POST with an empty URI. This is required when using the iam auth - method. + probably use POST with an empty URI. If using GET method this will contain + the authentication headers that have been hoisted out of the message body. + This is required when using the iam auth method. - `iam_request_body` `(string: )` - Base64-encoded body of the signed request. Most likely `QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==`, which is the base64 encoding of `Action=GetCallerIdentity&Version=2011-06-15`. This is - required when using the iam auth method. + required when using the iam auth method with POST signed requests. - `iam_request_headers` `(string: )` - Key/value pairs of headers for use in the `sts:GetCallerIdentity` HTTP requests headers. Can be either a Base64-encoded, JSON-serialized string, or a JSON object of key/value pairs. The