From d60c9fe0187520b0b93c6c6d94e14aaec8f22c0a Mon Sep 17 00:00:00 2001 From: Nick Williams Date: Wed, 24 Jun 2020 19:53:18 +0000 Subject: [PATCH] Implement IMDSv2 for AWS metadata service Fixes: #2482 Signed-off-by: Nick Williams --- plugins/rest/aws.go | 93 ++++++++++++++++++++++++++++++++-------- plugins/rest/aws_test.go | 77 +++++++++++++++++++++++++++------ 2 files changed, 138 insertions(+), 32 deletions(-) diff --git a/plugins/rest/aws.go b/plugins/rest/aws.go index 48c1b8bbd2..b294074cad 100644 --- a/plugins/rest/aws.go +++ b/plugins/rest/aws.go @@ -24,6 +24,9 @@ const ( // ref. https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html ec2DefaultCredServicePath = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" + // ref. https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html + ec2DefaultTokenPath = "http://169.254.169.254/latest/api/token" + // ref. https://docs.aws.amazon.com/AmazonECS/latest/userguide/task-iam-roles.html ecsDefaultCredServicePath = "http://169.254.170.2" ecsRelativePathEnvVar = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" @@ -85,6 +88,7 @@ type awsMetadataCredentialService struct { creds awsCredentials expiration time.Time credServicePath string + tokenPath string } func (cs *awsMetadataCredentialService) urlForMetadataService() (string, error) { @@ -99,15 +103,30 @@ func (cs *awsMetadataCredentialService) urlForMetadataService() (string, error) } // otherwise, check environment to see if it looks like we're in an ECS // container (with implied role association) - ecsRelativePath, isECS := os.LookupEnv(ecsRelativePathEnvVar) - if isECS { - return ecsDefaultCredServicePath + ecsRelativePath, nil + if isECS() { + return ecsDefaultCredServicePath + os.Getenv(ecsRelativePathEnvVar), nil } // if there's no role name and we don't appear to have a path to the // ECS container service, then the configuration is invalid return "", errors.New("metadata endpoint cannot be determined from settings and environment") } +func (cs *awsMetadataCredentialService) tokenRequest() (*http.Request, error) { + tokenURL := ec2DefaultTokenPath + if cs.tokenPath != "" { + // override for testing + tokenURL = cs.tokenPath + } + req, err := http.NewRequest(http.MethodPut, tokenURL, nil) + if err != nil { + return nil, err + } + + // we are going to use the token in the immediate future, so a long TTL is not necessary + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "60") + return req, nil +} + func (cs *awsMetadataCredentialService) refreshFromService() error { // define the expected JSON payload from the EC2 credential service // ref. https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html @@ -132,27 +151,31 @@ func (cs *awsMetadataCredentialService) refreshFromService() error { return err } - resp, err := http.Get(metaDataURL) + // construct an HTTP client with a reasonably short timeout + client := &http.Client{Timeout: time.Second * 10} + req, err := http.NewRequest(http.MethodGet, metaDataURL, nil) if err != nil { - // some kind of catastrophe talking to the EC2 metadata service - return err + return errors.New("unable to construct metadata HTTP request: " + err.Error()) } - defer resp.Body.Close() - logrus.WithFields(logrus.Fields{ - "url": metaDataURL, - "status": resp.Status, - "headers": resp.Header, - }).Debug("Received response from metadata service.") - - if resp.StatusCode != 200 { - // most probably a 404 due to a role that's not available; but cover all the bases - return errors.New("metadata service HTTP request failed: " + resp.Status) + // if in the EC2 environment, we will use IMDSv2, which requires a session cookie from a + // PUT request on the token endpoint before it will give the credentials, this provides + // protection from SSRF attacks + if !isECS() { + tokenReq, err := cs.tokenRequest() + if err != nil { + return errors.New("unable to construct metadata token HTTP request: " + err.Error()) + } + body, err := doMetaDataRequestWithClient(tokenReq, client, "metadata token") + if err != nil { + return err + } + // token is the body of response; add to header of metadata request + req.Header.Set("X-aws-ec2-metadata-token", string(body)) } - body, err := ioutil.ReadAll(resp.Body) + body, err := doMetaDataRequestWithClient(req, client, "metadata") if err != nil { - // deal with problems reading the body, whatever that might be return err } @@ -186,6 +209,40 @@ func (cs *awsMetadataCredentialService) credentials() (awsCredentials, error) { return cs.creds, nil } +func isECS() bool { + // the special relative path URI is set by the container agent in the ECS environment only + _, isECS := os.LookupEnv(ecsRelativePathEnvVar) + return isECS +} + +func doMetaDataRequestWithClient(req *http.Request, client *http.Client, desc string) ([]byte, error) { + // convenience function to get the body of an AWS EC2 metadata service request with + // appropriate error-handling boilerplate and logging for this special case + resp, err := client.Do(req) + if err != nil { + // some kind of catastrophe talking to the EC2 service + return nil, errors.New(desc + " HTTP request failed: " + err.Error()) + } + defer resp.Body.Close() + + logrus.WithFields(logrus.Fields{ + "url": req.URL.String(), + "status": resp.Status, + "headers": resp.Header, + }).Debug("Received response from " + desc + " service.") + + if resp.StatusCode != 200 { + // could be 404 for role that's not available, but cover all the bases + return nil, errors.New(desc + " HTTP request returned unexpected status: " + resp.Status) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + // deal with problems reading the body, whatever those might be + return nil, errors.New(desc + " HTTP response body could not be read: " + err.Error()) + } + return body, nil +} + func sha256MAC(message []byte, key []byte) []byte { mac := hmac.New(sha256.New, key) mac.Write(message) diff --git a/plugins/rest/aws_test.go b/plugins/rest/aws_test.go index 05b1d15183..b8f8743d55 100644 --- a/plugins/rest/aws_test.go +++ b/plugins/rest/aws_test.go @@ -103,7 +103,8 @@ func TestMetadataCredentialService(t *testing.T) { cs := awsMetadataCredentialService{ RoleName: "my_iam_role", RegionName: "us-east-1", - credServicePath: "this is not a URL"} // malformed + credServicePath: "this is not a URL", // malformed + tokenPath: ts.server.URL + "/latest/api/token"} _, err := cs.credentials() assertErr("unsupported protocol scheme \"\"", err, t) @@ -118,18 +119,38 @@ func TestMetadataCredentialService(t *testing.T) { cs = awsMetadataCredentialService{ RoleName: "not_my_iam_role", // not present RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} _, err = cs.credentials() - assertErr("metadata service HTTP request failed: 404 Not Found", err, t) + assertErr("metadata HTTP request returned unexpected status: 404 Not Found", err, t) // wrong path: malformed JSON body cs = awsMetadataCredentialService{ RoleName: "my_bad_iam_role", // not good RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} _, err = cs.credentials() assertErr("failed to parse credential response from metadata service: invalid character 'T' looking for beginning of value", err, t) + // wrong path: token service error + cs = awsMetadataCredentialService{ + RoleName: "my_iam_role", + RegionName: "us-east-1", + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/missing_token"} // will 404 + _, err = cs.credentials() + assertErr("metadata token HTTP request returned unexpected status: 404 Not Found", err, t) + + // wrong path: token service returns bad token + cs = awsMetadataCredentialService{ + RoleName: "my_iam_role", + RegionName: "us-east-1", + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/bad_token"} // not good + _, err = cs.credentials() + assertErr("metadata HTTP request returned unexpected status: 401 Unauthorized", err, t) + // wrong path: bad result code from EC2 metadata service ts.payload = metadataPayload{ AccessKeyID: "MYAWSACCESSKEYGOESHERE", @@ -140,7 +161,8 @@ func TestMetadataCredentialService(t *testing.T) { cs = awsMetadataCredentialService{ RoleName: "my_iam_role", RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} _, err = cs.credentials() assertErr("metadata service query did not succeed: Failure", err, t) @@ -154,7 +176,8 @@ func TestMetadataCredentialService(t *testing.T) { cs = awsMetadataCredentialService{ RoleName: "my_iam_role", RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} var creds awsCredentials creds, err = cs.credentials() @@ -177,7 +200,8 @@ func TestMetadataCredentialService(t *testing.T) { cs = awsMetadataCredentialService{ RoleName: "my_iam_role", RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} ts.payload = metadataPayload{ AccessKeyID: "MYAWSACCESSKEYGOESHERE", SecretAccessKey: "MYAWSSECRETACCESSKEYGOESHERE", @@ -220,17 +244,19 @@ func TestV4Signing(t *testing.T) { cs := &awsMetadataCredentialService{ RoleName: "not_my_iam_role", // not present RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} req, _ := http.NewRequest("GET", "https://mybucket.s3.amazonaws.com/bundle.tar.gz", strings.NewReader("")) err := signV4(req, cs, time.Unix(1556129697, 0)) - assertErr("error getting AWS credentials: metadata service HTTP request failed: 404 Not Found", err, t) + assertErr("error getting AWS credentials: metadata HTTP request returned unexpected status: 404 Not Found", err, t) // happy path: sign correctly cs = &awsMetadataCredentialService{ RoleName: "my_iam_role", // not present RegionName: "us-east-1", - credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/"} + credServicePath: ts.server.URL + "/latest/meta-data/iam/security-credentials/", + tokenPath: ts.server.URL + "/latest/api/token"} ts.payload = metadataPayload{ AccessKeyID: "MYAWSACCESSKEYGOESHERE", SecretAccessKey: "MYAWSSECRETACCESSKEYGOESHERE", @@ -268,15 +294,38 @@ type credTestServer struct { func (t *credTestServer) handle(w http.ResponseWriter, r *http.Request) { goodPath := "/latest/meta-data/iam/security-credentials/my_iam_role" badPath := "/latest/meta-data/iam/security-credentials/my_bad_iam_role" + + goodTokenPath := "/latest/api/token" + badTokenPath := "/latest/api/bad_token" + + tokenValue := "THIS_IS_A_GOOD_TOKEN" jsonBytes, _ := json.Marshal(t.payload) - if r.URL.Path == goodPath { + switch r.URL.Path { + case goodTokenPath: + // a valid token + w.WriteHeader(200) + w.Write([]byte(tokenValue)) + case badTokenPath: + // an invalid token w.WriteHeader(200) - w.Write(jsonBytes) - } else if r.URL.Path == badPath { + w.Write([]byte("THIS_IS_A_BAD_TOKEN")) + case goodPath: + // validate token... + if r.Header.Get("X-aws-ec2-metadata-token") == tokenValue { + // a metadata response that's well-formed + w.WriteHeader(200) + w.Write(jsonBytes) + } else { + // an unauthorized response + w.WriteHeader(401) + } + case badPath: + // a metadata response that's not well-formed w.WriteHeader(200) w.Write([]byte("This isn't a JSON payload")) - } else { + default: + // something else that we won't be able to find w.WriteHeader(404) } }