Skip to content

Commit

Permalink
Implement IMDSv2 for AWS metadata service
Browse files Browse the repository at this point in the history
Fixes: #2482
Signed-off-by: Nick Williams <nhw@me.com>
  • Loading branch information
nhw76 authored and patrick-east committed Jun 26, 2020
1 parent 0825b9a commit d60c9fe
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 32 deletions.
93 changes: 75 additions & 18 deletions plugins/rest/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -85,6 +88,7 @@ type awsMetadataCredentialService struct {
creds awsCredentials
expiration time.Time
credServicePath string
tokenPath string
}

func (cs *awsMetadataCredentialService) urlForMetadataService() (string, error) {
Expand All @@ -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
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
77 changes: 63 additions & 14 deletions plugins/rest/aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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",
Expand All @@ -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)

Expand All @@ -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()

Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
}
}
Expand Down

0 comments on commit d60c9fe

Please sign in to comment.