Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for signed GET requests for aws authentication #10961

Merged
merged 9 commits into from
Aug 15, 2023
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
}
bluestealth marked this conversation as resolved.
Show resolved Hide resolved
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"},
})
bluestealth marked this conversation as resolved.
Show resolved Hide resolved
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