From 547dfa87563685890e3c9a90bf5b75ac7de8ef53 Mon Sep 17 00:00:00 2001 From: "Chinmay D. Pai" Date: Sat, 29 Jun 2024 00:52:18 +0530 Subject: [PATCH 1/3] feat: add support for IMDSv2 Currently, IMDSv2 is not supported by simple-s3's `NewUsingIAM` method, causing it to fail on EC2 instances with IMDSv2 enabled in the EC2 metadata configuration. This patch adds a failsafe method that executes PUT request on the IMDSv2 token endpoint, and based on the response decides whether IMDSv2 support needs to be enabled. Doing so will not break or require any code-level changes to existing deployments, and allows the same binaries to run on both IMDSv2 enabled and optional instances. ref: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-v2-how-it-works.html Signed-off-by: Chinmay D. Pai --- simples3.go | 70 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/simples3.go b/simples3.go index 3d30d2d..e5e41a6 100644 --- a/simples3.go +++ b/simples3.go @@ -23,6 +23,8 @@ import ( ) const ( + imdsTokenURL = "http://169.254.169.254/latest/api/token" + imdsTokenHeader = "X-aws-ec2-metadata-token" securityCredentialsURL = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" // AMZMetaPrefix to prefix metadata key. @@ -89,12 +91,13 @@ type UploadInput struct { // UploadResponse receives the following XML // in case of success, since we set a 201 response from S3. // Sample response: -// -// https://s3.amazonaws.com/link-to-the-file -// s3-bucket -// development/8614bd40-691b-4668-9241-3b342c6cf429/image.jpg -// "32-bit-tag" -// +// +// +// https://s3.amazonaws.com/link-to-the-file +// s3-bucket +// development/8614bd40-691b-4668-9241-3b342c6cf429/image.jpg +// "32-bit-tag" +// type UploadResponse struct { Location string `xml:"Location"` Bucket string `xml:"Bucket"` @@ -149,12 +152,55 @@ func NewUsingIAM(region string) (*S3, error) { }, securityCredentialsURL, region) } +// fetchIMDSToken retrieves an IMDSv2 token from the +// EC2 instance metadata service. It returns a token +// only if IMDSv2 is enabled in the EC2 instance metadata +// configuration, otherwise returns an error. +func fetchIMDSToken(cl *http.Client) (string, error) { + req, err := http.NewRequest(http.MethodPut, imdsTokenURL, nil) + if err != nil { + return "", err + } + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600") + + resp, err := cl.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to fetch IMDSv2 token: %s", resp.Status) + } + + token, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(token), nil +} + // fetchIAMData fetches the IAM data from the given URL. // In case of a normal AWS setup, baseURL would be securityCredentialsURL. // You can use this method, to manually fetch IAM data from a custom // endpoint and pass it to SetIAMData. func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { - resp, err := cl.Get(baseURL) + var token string + token, err := fetchIMDSToken(cl) + // useIMDSv2 if no errors were returned from + // the fetchIMDSToken method call. + useIMDSv2 := err == nil + + req, err := http.NewRequest(http.MethodGet, baseURL, nil) + if err != nil { + return IAMResponse{}, err + } + if useIMDSv2 { + req.Header.Set(imdsTokenHeader, token) + } + + resp, err := cl.Do(req) if err != nil { return IAMResponse{}, err } @@ -169,7 +215,15 @@ func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { return IAMResponse{}, err } - resp, err = http.Get(baseURL + "/" + string(role)) + req, err = http.NewRequest(http.MethodGet, baseURL+"/"+string(role), nil) + if err != nil { + return IAMResponse{}, err + } + if useIMDSv2 { + req.Header.Set(imdsTokenHeader, token) + } + + resp, err = cl.Do(req) if err != nil { return IAMResponse{}, err } From 430bffb2e249d6208ef9fc4b518935717484b003 Mon Sep 17 00:00:00 2001 From: "Chinmay D. Pai" Date: Wed, 3 Jul 2024 13:29:48 +0530 Subject: [PATCH 2/3] chore: send useIMDSv2 bool from fetchIMDSToken method Instead of using the errors to figure out whether IMDSv2 needs to be used, we return a boolean value for useIMDSv2 from the function itself, and propagate the error to upstream function calls in the library. IMDSv2 tokens can be used in all cases, even when IMDSv2 is set to `Optional` in the EC2 Instance configuration. As such, calls with token will not fail and we should be able to send IMDSv2 requests with token even when using IMDSv1. Signed-off-by: Chinmay D. Pai --- simples3.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/simples3.go b/simples3.go index e5e41a6..4757bdc 100644 --- a/simples3.go +++ b/simples3.go @@ -25,6 +25,7 @@ import ( const ( imdsTokenURL = "http://169.254.169.254/latest/api/token" imdsTokenHeader = "X-aws-ec2-metadata-token" + imdsTokenTtlHeader = "X-aws-ec2-metadata-token-ttl-seconds" securityCredentialsURL = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" // AMZMetaPrefix to prefix metadata key. @@ -153,32 +154,32 @@ func NewUsingIAM(region string) (*S3, error) { } // fetchIMDSToken retrieves an IMDSv2 token from the -// EC2 instance metadata service. It returns a token +// EC2 instance metadata service. It returns a token and boolean, // only if IMDSv2 is enabled in the EC2 instance metadata // configuration, otherwise returns an error. -func fetchIMDSToken(cl *http.Client) (string, error) { +func fetchIMDSToken(cl *http.Client) (string, bool, error) { req, err := http.NewRequest(http.MethodPut, imdsTokenURL, nil) if err != nil { - return "", err + return "", false, err } - req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "21600") + req.Header.Set(imdsTokenTtlHeader, "21600") resp, err := cl.Do(req) if err != nil { - return "", err + return "", false, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to fetch IMDSv2 token: %s", resp.Status) + return "", false, fmt.Errorf("failed to request IMDSv2 token: %s", resp.Status) } token, err := ioutil.ReadAll(resp.Body) if err != nil { - return "", err + return "", false, err } - return string(token), nil + return string(token), true, nil } // fetchIAMData fetches the IAM data from the given URL. @@ -187,10 +188,11 @@ func fetchIMDSToken(cl *http.Client) (string, error) { // endpoint and pass it to SetIAMData. func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { var token string - token, err := fetchIMDSToken(cl) - // useIMDSv2 if no errors were returned from - // the fetchIMDSToken method call. - useIMDSv2 := err == nil + var useIMDSv2 bool + token, useIMDSv2, err := fetchIMDSToken(cl) + if err != nil { + return IAMResponse{}, fmt.Errorf("Error fetching IMDSv2 token: %w", err) + } req, err := http.NewRequest(http.MethodGet, baseURL, nil) if err != nil { From 1cf2d7d2504f31582e67e6e4a76dabb14ad3d4f3 Mon Sep 17 00:00:00 2001 From: Rohan Verma Date: Wed, 3 Jul 2024 16:19:50 +0530 Subject: [PATCH 3/3] fix: add tests for imdsv2 --- simples3.go | 55 +++++++++++++++++--------------- simples3_test.go | 81 ++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 94 insertions(+), 42 deletions(-) diff --git a/simples3.go b/simples3.go index 4757bdc..92035f3 100644 --- a/simples3.go +++ b/simples3.go @@ -10,7 +10,6 @@ import ( "encoding/hex" "encoding/json" "encoding/xml" - "errors" "fmt" "io" "io/ioutil" @@ -23,10 +22,12 @@ import ( ) const ( - imdsTokenURL = "http://169.254.169.254/latest/api/token" imdsTokenHeader = "X-aws-ec2-metadata-token" imdsTokenTtlHeader = "X-aws-ec2-metadata-token-ttl-seconds" - securityCredentialsURL = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" + metadataBaseURL = "http://169.254.169.254/latest" + securityCredentialsURI = "/meta-data/iam/security-credentials/" + imdsTokenURI = "/api/token" + defaultIMDSTokenTTL = "60" // AMZMetaPrefix to prefix metadata key. AMZMetaPrefix = "x-amz-meta-" @@ -150,19 +151,21 @@ func NewUsingIAM(region string) (*S3, error) { &http.Client{ // Set a timeout of 3 seconds for AWS IAM Calls. Timeout: time.Second * 3, //nolint:gomnd - }, securityCredentialsURL, region) + }, metadataBaseURL, region) } // fetchIMDSToken retrieves an IMDSv2 token from the // EC2 instance metadata service. It returns a token and boolean, // only if IMDSv2 is enabled in the EC2 instance metadata // configuration, otherwise returns an error. -func fetchIMDSToken(cl *http.Client) (string, bool, error) { - req, err := http.NewRequest(http.MethodPut, imdsTokenURL, nil) +func fetchIMDSToken(cl *http.Client, baseURL string) (string, bool, error) { + req, err := http.NewRequest(http.MethodPut, baseURL+imdsTokenURI, nil) if err != nil { return "", false, err } - req.Header.Set(imdsTokenTtlHeader, "21600") + + // Set the token TTL to 60 seconds. + req.Header.Set(imdsTokenTtlHeader, defaultIMDSTokenTTL) resp, err := cl.Do(req) if err != nil { @@ -183,21 +186,22 @@ func fetchIMDSToken(cl *http.Client) (string, bool, error) { } // fetchIAMData fetches the IAM data from the given URL. -// In case of a normal AWS setup, baseURL would be securityCredentialsURL. +// In case of a normal AWS setup, baseURL would be metadataBaseURL. // You can use this method, to manually fetch IAM data from a custom // endpoint and pass it to SetIAMData. func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { - var token string - var useIMDSv2 bool - token, useIMDSv2, err := fetchIMDSToken(cl) + token, useIMDSv2, err := fetchIMDSToken(cl, baseURL) if err != nil { - return IAMResponse{}, fmt.Errorf("Error fetching IMDSv2 token: %w", err) + return IAMResponse{}, fmt.Errorf("error fetching IMDSv2 token: %w", err) } - req, err := http.NewRequest(http.MethodGet, baseURL, nil) + url := baseURL + securityCredentialsURI + + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return IAMResponse{}, err + return IAMResponse{}, fmt.Errorf("error creating imdsv2 token request: %w", err) } + if useIMDSv2 { req.Header.Set(imdsTokenHeader, token) } @@ -209,7 +213,7 @@ func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return IAMResponse{}, fmt.Errorf("Error fetching IAM data: %s", resp.Status) + return IAMResponse{}, fmt.Errorf("error fetching IAM data: %s", resp.Status) } role, err := ioutil.ReadAll(resp.Body) @@ -217,9 +221,9 @@ func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { return IAMResponse{}, err } - req, err = http.NewRequest(http.MethodGet, baseURL+"/"+string(role), nil) + req, err = http.NewRequest(http.MethodGet, url+string(role), nil) if err != nil { - return IAMResponse{}, err + return IAMResponse{}, fmt.Errorf("error creating role request: %w", err) } if useIMDSv2 { req.Header.Set(imdsTokenHeader, token) @@ -227,31 +231,32 @@ func fetchIAMData(cl *http.Client, baseURL string) (IAMResponse, error) { resp, err = cl.Do(req) if err != nil { - return IAMResponse{}, err + return IAMResponse{}, fmt.Errorf("error fetching role data: %w", err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { - return IAMResponse{}, errors.New(http.StatusText(resp.StatusCode)) + return IAMResponse{}, fmt.Errorf("error fetching role data, got non 200 code: %s", resp.Status) } var jResp IAMResponse jsonString, err := ioutil.ReadAll(resp.Body) if err != nil { - return IAMResponse{}, err + return IAMResponse{}, fmt.Errorf("error reading role data: %w", err) } if err := json.Unmarshal(jsonString, &jResp); err != nil { - return IAMResponse{}, err + return IAMResponse{}, fmt.Errorf("error unmarshalling role data: %w (%s)", err, jsonString) } return jResp, nil } -func newUsingIAM(cl *http.Client, credUrl, region string) (*S3, error) { +func newUsingIAM(cl *http.Client, baseURL, region string) (*S3, error) { // Get the IAM role - iamResp, err := fetchIAMData(cl, credUrl) + iamResp, err := fetchIAMData(cl, baseURL) if err != nil { - return nil, fmt.Errorf("Error fetching IAM data: %w", err) + return nil, fmt.Errorf("error fetching IAM data: %w", err) } return &S3{ @@ -265,7 +270,7 @@ func newUsingIAM(cl *http.Client, credUrl, region string) (*S3, error) { } // setIAMData sets the IAM data on the S3 instance. -func (s3 *S3) setIAMData(iamResp IAMResponse) { +func (s3 *S3) SetIAMData(iamResp IAMResponse) { s3.AccessKey = iamResp.AccessKeyID s3.SecretKey = iamResp.SecretAccessKey s3.Token = iamResp.Token diff --git a/simples3_test.go b/simples3_test.go index 70d34f8..c0aba0a 100644 --- a/simples3_test.go +++ b/simples3_test.go @@ -400,6 +400,7 @@ func TestS3_NewUsingIAM(t *testing.T) { "Type" : "AWS-HMAC","AccessKeyId" : "abc", "SecretAccessKey" : "abc","Token" : "abc", "Expiration" : "2018-12-24T16:24:59Z"}` + respIMDSToken = `AQAEAJWopi8yvjKYXyWJbzESE0cms-OoTnptJzS3M9g5iNcl06UEkQ==` ) tsFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -407,42 +408,88 @@ func TestS3_NewUsingIAM(t *testing.T) { })) defer tsFail.Close() - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" { - t.Errorf("Expected 'GET' request, got '%s'", r.Method) + genServerHandlerFunc := func(failIMDS bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + if !failIMDS { + // check if token is present + if r.Header.Get(imdsTokenHeader) == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + } + + url := securityCredentialsURI + if r.URL.EscapedPath() == url { + w.WriteHeader(http.StatusOK) + io.WriteString(w, iam) + } + if r.URL.EscapedPath() == url+iam { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, resp) + } + case "PUT": + if failIMDS { + w.WriteHeader(http.StatusNotFound) + return + } + + if r.URL.EscapedPath() == imdsTokenURI { + if r.Header.Get(imdsTokenTtlHeader) != "60" { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + io.WriteString(w, respIMDSToken) + } + default: + t.Errorf("Expected 'GET' or 'PUT' request, got '%s'", r.Method) + } } - if r.URL.EscapedPath() == "/" { - w.WriteHeader(http.StatusOK) - io.WriteString(w, iam) - } - if r.URL.EscapedPath() == "/"+iam { - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "application/json") - io.WriteString(w, resp) - } - })) + } + + ts := httptest.NewServer(http.HandlerFunc(genServerHandlerFunc(false))) defer ts.Close() + tsFailIMDS := httptest.NewServer(http.HandlerFunc(genServerHandlerFunc(true))) + defer tsFailIMDS.Close() + + cl := &http.Client{Timeout: 1 * time.Second} + // Test for timeout. - _, err := newUsingIAM(&http.Client{Timeout: 1 * time.Second}, tsFail.URL, "abc") + _, err := newUsingIAM(cl, tsFail.URL, "abc") if err == nil { t.Errorf("Expected error, got nil") } else { var timeoutError net.Error - if errors.As(err, &timeoutError); !timeoutError.Timeout() { + + if errors.As(err, &timeoutError) && !timeoutError.Timeout() { t.Errorf("newUsingIAM() timeout check. got error = %v", err) } } // Test for successful IAM fetch. - s3, err := newUsingIAM(http.DefaultClient, ts.URL, "abc") + s3, err := newUsingIAM(cl, ts.URL, "abc") if err != nil { t.Errorf("newUsingIAM() error = %v", err) } - if s3.AccessKey != "abc" && s3.SecretKey != "abc" && s3.Region != "abc" { + if s3 == nil { + t.Errorf("newUsingIAM() got = %v", s3) + } + + if s3.AccessKey != "abc" || s3.SecretKey != "abc" || s3.Region != "abc" { t.Errorf("S3.FileDelete() got = %v", s3) } + + // Test for failed IMDS token fetch. + _, err = newUsingIAM(cl, tsFailIMDS.URL, "abc") + if err == nil { + t.Errorf("Expected error, got nil") + } } func TestCustomEndpoint(t *testing.T) {