open-consul/internal/iamauth/token.go

404 lines
12 KiB
Go

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
}