package iamauth import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/textproto" "net/url" "strings" "github.com/hashicorp/consul/lib/stringslice" ) const ( amzHeaderPrefix = "X-Amz-" ) var defaultAllowedSTSRequestHeaders = []string{ "X-Amz-Algorithm", "X-Amz-Content-Sha256", "X-Amz-Credential", "X-Amz-Date", "X-Amz-Security-Token", "X-Amz-Signature", "X-Amz-SignedHeaders", } // BearerToken is a login "token" for an IAM auth method. It is a signed // sts:GetCallerIdentity request in JSON format. Optionally, it can include a // signed embedded iam:GetRole or iam:GetUser request in the headers. type BearerToken struct { config *Config getCallerIdentityMethod string getCallerIdentityURL string getCallerIdentityHeader http.Header getCallerIdentityBody string getIAMEntityMethod string getIAMEntityURL string getIAMEntityHeader http.Header getIAMEntityBody string entityRequestType string parsedCallerIdentityURL *url.URL parsedIAMEntityURL *url.URL } var _ json.Unmarshaler = (*BearerToken)(nil) func NewBearerToken(loginToken string, config *Config) (*BearerToken, error) { token := &BearerToken{config: config} if err := json.Unmarshal([]byte(loginToken), &token); err != nil { return nil, fmt.Errorf("invalid token: %s", err) } if err := token.validate(); err != nil { return nil, err } if config.EnableIAMEntityDetails { method, err := token.getHeader(token.config.GetEntityMethodHeader) if err != nil { return nil, err } rawUrl, err := token.getHeader(token.config.GetEntityURLHeader) if err != nil { return nil, err } headerJson, err := token.getHeader(token.config.GetEntityHeadersHeader) if err != nil { return nil, err } var header http.Header if err := json.Unmarshal([]byte(headerJson), &header); err != nil { return nil, err } body, err := token.getHeader(token.config.GetEntityBodyHeader) if err != nil { return nil, err } parsedUrl, err := parseUrl(rawUrl) if err != nil { return nil, err } token.getIAMEntityMethod = method token.getIAMEntityBody = body token.getIAMEntityURL = rawUrl token.getIAMEntityHeader = header token.parsedIAMEntityURL = parsedUrl if err := token.validateIAMHostname(); err != nil { return nil, err } reqType, err := token.validateIAMEntityBody() if err != nil { return nil, err } token.entityRequestType = reqType } return token, nil } // https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1178 func (t *BearerToken) validate() error { if t.getCallerIdentityMethod != "POST" { return fmt.Errorf("iam_http_request_method must be POST") } if err := t.validateSTSHostname(); err != nil { return err } if err := t.validateGetCallerIdentityBody(); err != nil { return err } if err := t.validateAllowedSTSHeaderValues(); err != nil { return err } return nil } // validateSTSHostname checks the CallerIdentityURL in the BearerToken // either matches the admin configured STSEndpoint or, if STSEndpoint is not set, // that the URL matches a known Amazon AWS hostname for the STS service, one of: // // sts.amazonaws.com // sts.*.amazonaws.com // sts-fips.amazonaws.com // sts-fips.*.amazonaws.com // // See https://docs.aws.amazon.com/general/latest/gr/sts.html func (t *BearerToken) validateSTSHostname() error { if t.config.STSEndpoint != "" { // If an STS endpoint is configured, we (elsewhere) send the request to that endpoint. return nil } if t.parsedCallerIdentityURL == nil { return fmt.Errorf("invalid GetCallerIdentity URL: %v", t.getCallerIdentityURL) } // Otherwise, validate the hostname looks like a known STS endpoint. host := t.parsedCallerIdentityURL.Hostname() if strings.HasSuffix(host, ".amazonaws.com") && (strings.HasPrefix(host, "sts.") || strings.HasPrefix(host, "sts-fips.")) { return nil } return fmt.Errorf("invalid STS hostname: %q", host) } // validateIAMHostname checks the IAMEntityURL in the BearerToken // either matches the admin configured IAMEndpoint or, if IAMEndpoint is not set, // that the URL matches a known Amazon AWS hostname for the IAM service, one of: // // iam.amazonaws.com // iam.*.amazonaws.com // iam-fips.amazonaws.com // iam-fips.*.amazonaws.com // // See https://docs.aws.amazon.com/general/latest/gr/iam-service.html func (t *BearerToken) validateIAMHostname() error { if t.config.IAMEndpoint != "" { // If an IAM endpoint is configured, we (elsewhere) send the request to that endpoint. return nil } if t.parsedIAMEntityURL == nil { return fmt.Errorf("invalid IAM URL: %v", t.getIAMEntityURL) } // Otherwise, validate the hostname looks like a known IAM endpoint. host := t.parsedIAMEntityURL.Hostname() if strings.HasSuffix(host, ".amazonaws.com") && (strings.HasPrefix(host, "iam.") || strings.HasPrefix(host, "iam-fips.")) { return nil } return fmt.Errorf("invalid IAM hostname: %q", host) } // https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1439 func (t *BearerToken) validateGetCallerIdentityBody() error { allowedValues := url.Values{ "Action": []string{"GetCallerIdentity"}, // Will assume for now that future versions don't change // the semantics "Version": nil, // any value is allowed } if _, err := parseRequestBody(t.getCallerIdentityBody, allowedValues); err != nil { return fmt.Errorf("iam_request_body error: %s", err) } return nil } func (t *BearerToken) validateIAMEntityBody() (string, error) { allowedValues := url.Values{ "Action": []string{"GetRole", "GetUser"}, "RoleName": nil, // any value is allowed "UserName": nil, "Version": nil, } body, err := parseRequestBody(t.getIAMEntityBody, allowedValues) if err != nil { return "", fmt.Errorf("iam_request_headers[%s] error: %s", t.config.GetEntityBodyHeader, err) } // Disallow GetRole+UserName and GetUser+RoleName. action := body["Action"][0] _, hasRoleName := body["RoleName"] _, hasUserName := body["UserName"] if action == "GetUser" && hasUserName && !hasRoleName { return action, nil } else if action == "GetRole" && hasRoleName && !hasUserName { return action, nil } return "", fmt.Errorf("iam_request_headers[%q] error: invalid request body %q", t.config.GetEntityBodyHeader, t.getIAMEntityBody) } // parseRequestBody parses the AWS STS or IAM request body, such as 'Action=GetRole&RoleName=my-role'. // It returns the parsed values, or an error if there are unexpected fields based on allowedValues. // // A key-value pair in the body is allowed if: // - It is a single value (i.e. no bodies like 'Action=1&Action=2') // - allowedValues[key] is an empty slice or nil (any value is allowed for the key) // - allowedValues[key] is non-empty and contains the exact value // This always requires an 'Action' field is present and non-empty. func parseRequestBody(body string, allowedValues url.Values) (url.Values, error) { qs, err := url.ParseQuery(body) if err != nil { return nil, err } // Action field is always required. if _, ok := qs["Action"]; !ok || len(qs["Action"]) == 0 || qs["Action"][0] == "" { return nil, fmt.Errorf(`missing field "Action"`) } // Ensure the body does not have extra fields and each // field in the body matches the allowed values. for k, v := range qs { exp, ok := allowedValues[k] if k != "Action" && !ok { return nil, fmt.Errorf("unexpected field %q", k) } if len(exp) == 0 { // empty indicates any value is okay continue } else if len(v) != 1 || !stringslice.Contains(exp, v[0]) { return nil, fmt.Errorf("unexpected value %s=%v", k, v) } } return qs, nil } // https://github.com/hashicorp/vault/blob/861454e0ed1390d67ddaf1a53c1798e5e291728c/builtin/credential/aws/path_config_client.go#L349 func (t *BearerToken) validateAllowedSTSHeaderValues() error { for k := range t.getCallerIdentityHeader { h := textproto.CanonicalMIMEHeaderKey(k) if strings.HasPrefix(h, amzHeaderPrefix) && !stringslice.Contains(defaultAllowedSTSRequestHeaders, h) && !stringslice.Contains(t.config.AllowedSTSHeaderValues, h) { return fmt.Errorf("invalid request header: %s", h) } } return nil } // UnmarshalJSON unmarshals the bearer token details which contains an HTTP // request (a signed sts:GetCallerIdentity request). func (t *BearerToken) UnmarshalJSON(data []byte) error { var rawData struct { Method string `json:"iam_http_request_method"` UrlBase64 string `json:"iam_request_url"` HeadersBase64 string `json:"iam_request_headers"` BodyBase64 string `json:"iam_request_body"` } if err := json.Unmarshal(data, &rawData); err != nil { return err } rawUrl, err := base64.StdEncoding.DecodeString(rawData.UrlBase64) if err != nil { return err } headersJson, err := base64.StdEncoding.DecodeString(rawData.HeadersBase64) if err != nil { return err } var headers http.Header // This is a JSON-string in JSON if err := json.Unmarshal(headersJson, &headers); err != nil { return err } body, err := base64.StdEncoding.DecodeString(rawData.BodyBase64) if err != nil { return err } t.getCallerIdentityMethod = rawData.Method t.getCallerIdentityBody = string(body) t.getCallerIdentityHeader = headers t.getCallerIdentityURL = string(rawUrl) parsedUrl, err := parseUrl(t.getCallerIdentityURL) if err != nil { return err } t.parsedCallerIdentityURL = parsedUrl return nil } func parseUrl(s string) (*url.URL, error) { u, err := url.Parse(s) if err != nil { return nil, err } // url.Parse doesn't error on empty string if u == nil || u.Scheme == "" || u.Host == "" { return nil, fmt.Errorf("url is invalid: %q", s) } return u, nil } // GetCallerIdentityRequest returns the sts:GetCallerIdentity request decoded // from the bearer token. func (t *BearerToken) GetCallerIdentityRequest() (*http.Request, error) { // NOTE: We need to ensure we're calling STS, instead of acting as an unintended network proxy // We validate up-front that t.getCallerIdentityURL is a known AWS STS hostname. // Otherwise, we send to the admin-configured STSEndpoint. endpoint := t.getCallerIdentityURL if t.config.STSEndpoint != "" { endpoint = t.config.STSEndpoint } return buildHttpRequest( t.getCallerIdentityMethod, endpoint, t.parsedCallerIdentityURL, t.getCallerIdentityBody, t.getCallerIdentityHeader, ) } // GetEntityRequest returns the iam:GetUser or iam:GetRole request from the request details, // if present, embedded in the headers of the sts:GetCallerIdentity request. func (t *BearerToken) GetEntityRequest() (*http.Request, error) { endpoint := t.getIAMEntityURL if t.config.IAMEndpoint != "" { endpoint = t.config.IAMEndpoint } return buildHttpRequest( t.getIAMEntityMethod, endpoint, t.parsedIAMEntityURL, t.getIAMEntityBody, t.getIAMEntityHeader, ) } // getHeader returns the header from s.GetCallerIdentityHeader, or an error if // the header is not found or is not a single value. func (t *BearerToken) getHeader(name string) (string, error) { values := t.getCallerIdentityHeader.Values(name) if len(values) == 0 { return "", fmt.Errorf("missing header %q", name) } if len(values) != 1 { return "", fmt.Errorf("invalid value for header %q (expected 1 item)", name) } return values[0], nil } // buildHttpRequest returns an HTTP request from the given details. // This supports sending to a custom endpoint, but always preserves the // Host header and URI path, which are signed and cannot be modified. // There's a deeper explanation of this in the Vault source code. // https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1569 func buildHttpRequest(method, endpoint string, parsedUrl *url.URL, body string, headers http.Header) (*http.Request, error) { targetUrl := fmt.Sprintf("%s%s", endpoint, parsedUrl.RequestURI()) request, err := http.NewRequest(method, targetUrl, strings.NewReader(body)) if err != nil { return nil, err } request.Host = parsedUrl.Host for k, vals := range headers { for _, val := range vals { request.Header.Add(k, val) } } return request, nil }