Skip to content

Commit

Permalink
Add support for signed GET requests for aws authentication (#10961)
Browse files Browse the repository at this point in the history
* Support GET requests for aws-iam

This is required to support presigned requests from aws-sdk-go-v2

* Add GET method tests for aws-iam auth login path

* Update Website Documenation

* Validate GET action even if iam-server header is not set

* Combine URL checks

* Add const amzSignedHeaders to aws credential builtin

* Add test for multiple GET request actions

* Add Changelog Entry

---------

Co-authored-by: Max Coulombe <109547106+maxcoulombe@users.noreply.github.com>
  • Loading branch information
bluestealth and maxcoulombe committed Aug 15, 2023
1 parent 47cbcd5 commit d6b7e5b
Show file tree
Hide file tree
Showing 6 changed files with 224 additions and 47 deletions.
4 changes: 3 additions & 1 deletion builtin/credential/aws/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

const (
amzHeaderPrefix = "X-Amz-"
amzSignedHeaders = "X-Amz-SignedHeaders"
operationPrefixAWS = "aws"
)

Expand All @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions builtin/credential/aws/path_config_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/textproto"
"net/url"
"strings"

"github.com/aws/aws-sdk-go/aws"
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
`
Expand Down
95 changes: 62 additions & 33 deletions builtin/credential/aws/path_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -305,14 +300,19 @@ 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
}
}
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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
140 changes: 132 additions & 8 deletions builtin/credential/aws/path_login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading

0 comments on commit d6b7e5b

Please sign in to comment.