Add support for signed GET requests for aws authentication (#10961)

* 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>
This commit is contained in:
Michael Dempsey
2023-08-15 12:40:12 -07:00
committed by GitHub
parent 47cbcd50c4
commit d6b7e5bfa1
6 changed files with 224 additions and 47 deletions

View File

@@ -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) {

View File

@@ -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.
`

View File

@@ -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")
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)
}
if len(matches) > 2 {
return fmt.Errorf("found multiple SignedHeaders components")
}
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 {

View File

@@ -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 = validateVaultHeaderValue(postHeadersSplit, requestURL, canaryHeaderValue)
err = validateLoginIamRequestUrl(http.MethodPost, postRequestURL)
if err != nil {
t.Errorf("did NOT validate valid POST request: %v", err)
}
err = validateVaultHeaderValue(http.MethodPost, postHeadersSplit, postRequestURL, canaryHeaderValue)
if err != nil {
t.Errorf("did NOT validate valid POST request with split Authorization header: %v", err)
}

3
changelog/10961.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
auth/aws: Added support for signed GET requests for authenticating to vault using the aws iam method.
```

View File

@@ -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: <required-iam>)` - 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: <required-iam>)` - 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: <required-iam>)` - 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: <required-iam>)` - 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