diff --git a/builtin/credential/aws/backend_test.go b/builtin/credential/aws/backend_test.go index 4e92c17300be..5d494506ace0 100644 --- a/builtin/credential/aws/backend_test.go +++ b/builtin/credential/aws/backend_test.go @@ -20,6 +20,10 @@ import ( logicaltest "github.com/hashicorp/vault/logical/testing" ) +const testVaultHeaderValue = "VaultAcceptanceTesting" +const testValidRoleName = "valid-role" +const testInvalidRoleName = "invalid-role" + func TestBackend_CreateParseVerifyRoleTag(t *testing.T) { // create a backend config := logical.TestBackendConfig() @@ -1510,9 +1514,6 @@ func TestBackendAcc_LoginWithCallerIdentity(t *testing.T) { // it allows us to login to our role // 6. Pass in a request that has a validly signed request, asking for // the other role, ensure it fails - const testVaultHeaderValue = "VaultAcceptanceTesting" - const testValidRoleName = "valid-role" - const testInvalidRoleName = "invalid-role" clientConfigData := map[string]interface{}{ "iam_server_id_header_value": testVaultHeaderValue, diff --git a/builtin/credential/aws/path_login.go b/builtin/credential/aws/path_login.go index 950ff5159fbd..57b90c9b35b4 100644 --- a/builtin/credential/aws/path_login.go +++ b/builtin/credential/aws/path_login.go @@ -5,14 +5,12 @@ import ( "crypto/subtle" "crypto/x509" "encoding/base64" - "encoding/json" "encoding/pem" "encoding/xml" "fmt" "io/ioutil" "net/http" "net/url" - "reflect" "regexp" "strings" "time" @@ -89,10 +87,11 @@ when using iam auth_type.`, This must match the request body included in the signature.`, }, "iam_request_headers": { - Type: framework.TypeString, - Description: `Base64-encoded JSON representation of the request headers when auth_type is -iam. This must at a minimum include the headers over -which AWS has included a signature.`, + Type: framework.TypeHeader, + Description: `Key/value pairs of headers for use in the +sts:GetCallerIdentity HTTP requests headers when auth_type is iam. Can be either +a Base64-encoded, JSON-serialized string, or a JSON object of key/value pairs. +This must at a minimum include the headers over which AWS has included a signature.`, }, "identity": { Type: framework.TypeString, @@ -202,7 +201,7 @@ func validateMetadata(clientNonce, pendingTime string, storedIdentity *whitelist } // If reauthentication is disabled or if the nonce supplied matches a - // predefied nonce which indicates reauthentication to be disabled, + // predefined nonce which indicates reauthentication to be disabled, // authentication will not succeed. if storedIdentity.DisallowReauthentication || subtle.ConstantTimeCompare([]byte(reauthenticationDisabledNonce), []byte(clientNonce)) == 1 { @@ -1149,17 +1148,10 @@ func (b *backend) pathLoginUpdateIam(ctx context.Context, req *logical.Request, } body := string(bodyRaw) - headersB64 := data.Get("iam_request_headers").(string) - if headersB64 == "" { + headers := data.Get("iam_request_headers").(http.Header) + if len(headers) == 0 { return logical.ErrorResponse("missing iam_request_headers"), nil } - headers, err := parseIamRequestHeaders(headersB64) - if err != nil { - return logical.ErrorResponse(fmt.Sprintf("Error parsing iam_request_headers: %v", err)), nil - } - if headers == nil { - return logical.ErrorResponse("nil response when parsing iam_request_headers"), nil - } config, err := b.lockedClientConfigEntry(ctx, req.Storage) if err != nil { @@ -1491,41 +1483,6 @@ func parseGetCallerIdentityResponse(response string) (GetCallerIdentityResponse, return result, err } -func parseIamRequestHeaders(headersB64 string) (http.Header, error) { - headersJson, err := base64.StdEncoding.DecodeString(headersB64) - if err != nil { - return nil, fmt.Errorf("failed to base64 decode iam_request_headers") - } - var headersDecoded map[string]interface{} - err = jsonutil.DecodeJSON(headersJson, &headersDecoded) - if err != nil { - return nil, errwrap.Wrapf(fmt.Sprintf("failed to JSON decode iam_request_headers %q: {{err}}", headersJson), err) - } - headers := make(http.Header) - for k, v := range headersDecoded { - switch typedValue := v.(type) { - case string: - headers.Add(k, typedValue) - case json.Number: - headers.Add(k, typedValue.String()) - case []interface{}: - for _, individualVal := range typedValue { - switch possibleStrVal := individualVal.(type) { - case string: - headers.Add(k, possibleStrVal) - case json.Number: - headers.Add(k, possibleStrVal.String()) - default: - return nil, fmt.Errorf("header %q contains value %q that has type %s, not string", k, individualVal, reflect.TypeOf(individualVal)) - } - } - default: - return nil, fmt.Errorf("header %q value %q has type %s, not string or []interface", k, typedValue, reflect.TypeOf(v)) - } - } - return headers, nil -} - func submitCallerIdentityRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) (*GetCallerIdentityResult, error) { // NOTE: We need to ensure we're calling STS, instead of acting as an unintended network proxy // The protection against this is that this method will only call the endpoint specified in the @@ -1536,6 +1493,7 @@ func submitCallerIdentityRequest(method, endpoint string, parsedUrl *url.URL, bo client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + response, err := client.Do(request) if err != nil { return nil, errwrap.Wrapf("error making request: {{err}}", err) diff --git a/builtin/credential/aws/path_login_test.go b/builtin/credential/aws/path_login_test.go index f813a5865df2..2e26e7279b48 100644 --- a/builtin/credential/aws/path_login_test.go +++ b/builtin/credential/aws/path_login_test.go @@ -1,13 +1,18 @@ package awsauth import ( - "encoding/base64" - "encoding/json" + "context" + "errors" "fmt" "net/http" + "net/http/httptest" "net/url" - "reflect" + "strings" "testing" + + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/hashicorp/vault/logical" ) func TestBackend_pathLogin_getCallerIdentityResponse(t *testing.T) { @@ -39,16 +44,16 @@ func TestBackend_pathLogin_getCallerIdentityResponse(t *testing.T) { if err != nil { t.Fatal(err) } - if parsed_arn := parsedUserResponse.GetCallerIdentityResult[0].Arn; parsed_arn != expectedUserArn { - t.Errorf("expected to parse arn %#v, got %#v", expectedUserArn, parsed_arn) + if parsedArn := parsedUserResponse.GetCallerIdentityResult[0].Arn; parsedArn != expectedUserArn { + t.Errorf("expected to parse arn %#v, got %#v", expectedUserArn, parsedArn) } parsedRoleResponse, err := parseGetCallerIdentityResponse(responseFromAssumedRole) if err != nil { t.Fatal(err) } - if parsed_arn := parsedRoleResponse.GetCallerIdentityResult[0].Arn; parsed_arn != expectedRoleArn { - t.Errorf("expected to parn arn %#v; got %#v", expectedRoleArn, parsed_arn) + if parsedArn := parsedRoleResponse.GetCallerIdentityResult[0].Arn; parsedArn != expectedRoleArn { + t.Errorf("expected to parn arn %#v; got %#v", expectedRoleArn, parsedArn) } _, err = parseGetCallerIdentityResponse("SomeRandomGibberish") @@ -113,7 +118,7 @@ func TestBackend_pathLogin_parseIamArn(t *testing.T) { func TestBackend_validateVaultHeaderValue(t *testing.T) { const canaryHeaderValue = "Vault-Server" - requestUrl, err := url.Parse("https://sts.amazonaws.com/") + requestURL, err := url.Parse("https://sts.amazonaws.com/") if err != nil { t.Fatalf("error parsing test URL: %v", err) } @@ -143,68 +148,259 @@ func TestBackend_validateVaultHeaderValue(t *testing.T) { "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(postHeadersMissing, requestURL, canaryHeaderValue) if err == nil { t.Error("validated POST request with missing Vault header") } - err = validateVaultHeaderValue(postHeadersInvalid, requestUrl, canaryHeaderValue) + err = validateVaultHeaderValue(postHeadersInvalid, requestURL, canaryHeaderValue) if err == nil { t.Error("validated POST request with invalid Vault header value") } - err = validateVaultHeaderValue(postHeadersUnsigned, requestUrl, canaryHeaderValue) + err = validateVaultHeaderValue(postHeadersUnsigned, requestURL, canaryHeaderValue) if err == nil { t.Error("validated POST request with unsigned Vault header") } - err = validateVaultHeaderValue(postHeadersValid, requestUrl, canaryHeaderValue) + err = validateVaultHeaderValue(postHeadersValid, requestURL, canaryHeaderValue) if err != nil { t.Errorf("did NOT validate valid POST request: %v", err) } - err = validateVaultHeaderValue(postHeadersSplit, requestUrl, canaryHeaderValue) + err = validateVaultHeaderValue(postHeadersSplit, requestURL, canaryHeaderValue) if err != nil { t.Errorf("did NOT validate valid POST request with split Authorization header: %v", err) } } -func TestBackend_pathLogin_parseIamRequestHeaders(t *testing.T) { - testIamParser := func(headers interface{}, expectedHeaders http.Header) error { - headersJson, err := json.Marshal(headers) - if err != nil { - return fmt.Errorf("unable to JSON encode headers: %v", err) - } - headersB64 := base64.StdEncoding.EncodeToString(headersJson) +// TestBackend_pathLogin_IAMHeaders tests login with iam_request_headers, +// supporting both base64 encoded string and JSON headers +func TestBackend_pathLogin_IAMHeaders(t *testing.T) { + storage := &logical.InmemStorage{} + config := logical.TestBackendConfig() + config.StorageView = storage + b, err := Backend(config) + if err != nil { + t.Fatal(err) + } - parsedHeaders, err := parseIamRequestHeaders(headersB64) - if err != nil { - return fmt.Errorf("error parsing encoded headers: %v", err) - } - if parsedHeaders == nil { - return fmt.Errorf("nil result from parsing headers") - } - if !reflect.DeepEqual(parsedHeaders, expectedHeaders) { - return fmt.Errorf("parsed headers not equal to input headers") - } - return nil + err = b.Setup(context.Background(), config) + if err != nil { + t.Fatal(err) } - headersGoStyle := http.Header{ - "Header1": []string{"Value1"}, - "Header2": []string{"Value2"}, + // sets up a test server to stand in for STS service + ts := setupIAMTestServer() + defer ts.Close() + + clientConfigData := map[string]interface{}{ + "iam_server_id_header_value": testVaultHeaderValue, + "sts_endpoint": ts.URL, } - headersMixedType := map[string]interface{}{ - "Header1": "Value1", - "Header2": []string{"Value2"}, + clientRequest := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config/client", + Storage: storage, + Data: clientConfigData, + } + _, err = b.HandleRequest(context.Background(), clientRequest) + if err != nil { + t.Fatal(err) + } + + // create a role entry + roleEntry := &awsRoleEntry{ + Version: currentRoleStorageVersion, + AuthType: iamAuthType, } - err := testIamParser(headersGoStyle, headersGoStyle) + if err := b.nonLockedSetAWSRole(context.Background(), storage, testValidRoleName, roleEntry); err != nil { + t.Fatalf("failed to set entry: %s", err) + } + + // create a baseline loginData map structure, including iam_request_headers + // already base64encoded. This is the "Default" loginData used for all tests. + // Each sub test can override the map's iam_request_headers entry + loginData, err := defaultLoginData() if err != nil { - t.Errorf("error parsing go-style headers: %v", err) + t.Fatal(err) } - err = testIamParser(headersMixedType, headersGoStyle) + + // expected errors for certain tests + missingHeaderErr := errors.New("error validating X-Vault-AWS-IAM-Server-ID header: missing header \"X-Vault-AWS-IAM-Server-ID\"") + parsingErr := errors.New("error making upstream request: error parsing STS response") + + testCases := []struct { + Name string + Header interface{} + ExpectErr error + }{ + { + Name: "Default", + }, + { + Name: "Map-complete", + Header: map[string]interface{}{ + "Content-Length": "43", + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + "User-Agent": "aws-sdk-go/1.14.24 (go1.11; darwin; amd64)", + "X-Amz-Date": "20180910T203328Z", + "X-Vault-Aws-Iam-Server-Id": "VaultAcceptanceTesting", + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180910/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=cdef5819b2e97f1ff0f3e898fd2621aa03af00a4ec3e019122c20e5482534bf4", + }, + }, + { + Name: "Map-incomplete", + Header: map[string]interface{}{ + "Content-Length": "43", + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + "User-Agent": "aws-sdk-go/1.14.24 (go1.11; darwin; amd64)", + "X-Amz-Date": "20180910T203328Z", + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180910/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=cdef5819b2e97f1ff0f3e898fd2621aa03af00a4ec3e019122c20e5482534bf4", + }, + ExpectErr: missingHeaderErr, + }, + { + Name: "JSON-complete", + Header: `{ + "Content-Length":"43", + "Content-Type":"application/x-www-form-urlencoded; charset=utf-8", + "User-Agent":"aws-sdk-go/1.14.24 (go1.11; darwin; amd64)", + "X-Amz-Date":"20180910T203328Z", + "X-Vault-Aws-Iam-Server-Id": "VaultAcceptanceTesting", + "Authorization":"AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180910/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=cdef5819b2e97f1ff0f3e898fd2621aa03af00a4ec3e019122c20e5482534bf4" + }`, + }, + { + Name: "JSON-incomplete", + Header: `{ + "Content-Length":"43", + "Content-Type":"application/x-www-form-urlencoded; charset=utf-8", + "User-Agent":"aws-sdk-go/1.14.24 (go1.11; darwin; amd64)", + "X-Amz-Date":"20180910T203328Z", + "X-Vault-Aws-Iam-Server-Id": "VaultAcceptanceTesting", + "Authorization":"AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180910/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id" + }`, + ExpectErr: parsingErr, + }, + { + Name: "Base64-complete", + Header: base64Complete(), + }, + { + Name: "Base64-incomplete-missing-header", + Header: base64MissingVaultID(), + ExpectErr: missingHeaderErr, + }, + { + Name: "Base64-incomplete-missing-auth-sig", + Header: base64MissingAuthField(), + ExpectErr: parsingErr, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + if tc.Header != nil { + loginData["iam_request_headers"] = tc.Header + } + + loginRequest := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "login", + Storage: storage, + Data: loginData, + } + + resp, err := b.HandleRequest(context.Background(), loginRequest) + if err != nil || resp == nil || resp.IsError() { + if tc.ExpectErr != nil && tc.ExpectErr.Error() == resp.Error().Error() { + return + } + t.Errorf("un expected failed login:\nresp: %#v\n\nerr: %v", resp, err) + } + }) + } +} + +func defaultLoginData() (map[string]interface{}, error) { + awsSession, err := session.NewSession() if err != nil { - t.Errorf("error parsing mixed-style headers: %v", err) + return nil, fmt.Errorf("failed to create session: %s", err) } + + stsService := sts.New(awsSession) + stsInputParams := &sts.GetCallerIdentityInput{} + stsRequestValid, _ := stsService.GetCallerIdentityRequest(stsInputParams) + stsRequestValid.HTTPRequest.Header.Add(iamServerIdHeader, testVaultHeaderValue) + stsRequestValid.HTTPRequest.Header.Add("Authorization", fmt.Sprintf("%s,%s,%s", + "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")) + stsRequestValid.Sign() + + return buildCallerIdentityLoginData(stsRequestValid.HTTPRequest, testValidRoleName) +} + +// setupIAMTestServer configures httptest server to intercept and respond to the +// IAM login path's invocation of submitCallerIdentityRequest (which does not +// use the AWS SDK), which receieves the mocked response responseFromUser +// containing user information matching the role. +func setupIAMTestServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + responseString := ` + + arn:aws:iam::123456789012:user/valid-role + ASOMETHINGSOMETHINGSOMETHING + 123456789012 + + + 7f4fc40c-853a-11e6-8848-8d035d01eb87 + +` + + auth := r.Header.Get("Authorization") + parts := strings.Split(auth, ",") + for i, s := range parts { + s = strings.TrimSpace(s) + key := strings.Split(s, "=") + parts[i] = key[0] + } + + // verify the "Authorization" header contains all the expected parts + expectedAuthParts := []string{"AWS4-HMAC-SHA256 Credential", "SignedHeaders", "Signature"} + var matchingCount int + for _, v := range parts { + for _, z := range expectedAuthParts { + if z == v { + matchingCount++ + } + } + } + if matchingCount != len(expectedAuthParts) { + responseString = "missing auth parts" + } + fmt.Fprintln(w, responseString) + })) +} + +// base64Complete returns a base64 encoded auth header as expected +func base64Complete() string { + min := `{"Authorization":["AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180907/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=97086b0531854844099fc52733fa2c88a2bfb54b2689600c6e249358a8353b52"],"Content-Length":["43"],"Content-Type":["application/x-www-form-urlencoded; charset=utf-8"],"User-Agent":["aws-sdk-go/1.14.24 (go1.11; darwin; amd64)"],"X-Amz-Date":["20180907T222145Z"],"X-Vault-Aws-Iam-Server-Id":["VaultAcceptanceTesting"]}` + return min +} + +// base64MissingVaultID returns a base64 encoded auth header, that omits the +// Vault ID header +func base64MissingVaultID() string { + min := `{"Authorization":["AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180907/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id, Signature=97086b0531854844099fc52733fa2c88a2bfb54b2689600c6e249358a8353b52"],"Content-Length":["43"],"Content-Type":["application/x-www-form-urlencoded; charset=utf-8"],"User-Agent":["aws-sdk-go/1.14.24 (go1.11; darwin; amd64)"],"X-Amz-Date":["20180907T222145Z"]}` + return min +} + +// base64MissingAuthField returns a base64 encoded Auth header, that omits the +// "Signature" part +func base64MissingAuthField() string { + min := `{"Authorization":["AWS4-HMAC-SHA256 Credential=AKIAJPQ466AIIQW4LPSQ/20180907/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-vault-aws-iam-server-id"],"Content-Length":["43"],"Content-Type":["application/x-www-form-urlencoded; charset=utf-8"],"User-Agent":["aws-sdk-go/1.14.24 (go1.11; darwin; amd64)"],"X-Amz-Date":["20180907T222145Z"],"X-Vault-Aws-Iam-Server-Id":["VaultAcceptanceTesting"]}` + return min } diff --git a/website/source/api/auth/aws/index.html.md b/website/source/api/auth/aws/index.html.md index 53ad18d2cb62..e5f6e8c7324e 100644 --- a/website/source/api/auth/aws/index.html.md +++ b/website/source/api/auth/aws/index.html.md @@ -923,15 +923,15 @@ along with its RSA digest can be supplied to this endpoint. `QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==` which is the base64 encoding of `Action=GetCallerIdentity&Version=2011-06-15`. This is required when using the iam auth method. -- `iam_request_headers` `(string: )` - Base64-encoded, - JSON-serialized representation of the sts:GetCallerIdentity HTTP request - headers. The JSON serialization assumes that each header key maps to either a - string value or an array of string values (though the length of that array - will probably only be one). If the `iam_server_id_header_value` is configured - in Vault for the aws auth mount, then the headers must include the - X-Vault-AWS-IAM-Server-ID header, its value must match the value configured, - and the header must be included in the signed headers. This is required when - using the iam auth method. +- `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 + JSON serialization assumes that each header key maps to either a string value or + an array of string values (though the length of that array will probably only be + one). If the `iam_server_id_header_value` is configured in Vault for the aws + auth mount, then the headers must include the X-Vault-AWS-IAM-Server-ID header, + its value must match the value configured, and the header must be included in + the signed headers. This is required when using the iam auth method. ### Sample Payload