mirror of https://github.com/hashicorp/consul
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
403 lines
12 KiB
403 lines
12 KiB
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 |
|
}
|
|
|