mirror of https://github.com/hashicorp/consul
Paul Glass
2 years ago
committed by
GitHub
21 changed files with 9 additions and 2514 deletions
@ -1,2 +0,0 @@
|
||||
This is an internal package to house the AWS IAM auth method utilities for potential |
||||
future extraction from Consul. |
@ -1,311 +0,0 @@
|
||||
package iamauth |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/xml" |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"regexp" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/hashicorp/consul/internal/iamauth/responses" |
||||
"github.com/hashicorp/consul/lib" |
||||
"github.com/hashicorp/consul/lib/stringslice" |
||||
"github.com/hashicorp/go-cleanhttp" |
||||
"github.com/hashicorp/go-hclog" |
||||
"github.com/hashicorp/go-retryablehttp" |
||||
) |
||||
|
||||
const ( |
||||
// Retry configuration
|
||||
retryWaitMin = 500 * time.Millisecond |
||||
retryWaitMax = 30 * time.Second |
||||
) |
||||
|
||||
type Authenticator struct { |
||||
config *Config |
||||
logger hclog.Logger |
||||
} |
||||
|
||||
type IdentityDetails struct { |
||||
EntityName string |
||||
EntityId string |
||||
AccountId string |
||||
|
||||
EntityPath string |
||||
EntityTags map[string]string |
||||
} |
||||
|
||||
func NewAuthenticator(config *Config, logger hclog.Logger) (*Authenticator, error) { |
||||
if err := config.Validate(); err != nil { |
||||
return nil, err |
||||
} |
||||
return &Authenticator{ |
||||
config: config, |
||||
logger: logger, |
||||
}, nil |
||||
} |
||||
|
||||
// ValidateLogin determines if the identity in the loginToken is permitted to login.
|
||||
// If so, it returns details about the identity. Otherwise, an error is returned.
|
||||
func (a *Authenticator) ValidateLogin(ctx context.Context, loginToken string) (*IdentityDetails, error) { |
||||
token, err := NewBearerToken(loginToken, a.config) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
req, err := token.GetCallerIdentityRequest() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if a.config.ServerIDHeaderValue != "" { |
||||
err := validateHeaderValue(req.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
callerIdentity, err := a.submitCallerIdentityRequest(ctx, req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
a.logger.Debug("iamauth login attempt", "arn", callerIdentity.Arn) |
||||
|
||||
entity, err := responses.ParseArn(callerIdentity.Arn) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
identityDetails := &IdentityDetails{ |
||||
EntityName: entity.FriendlyName, |
||||
// This could either be a "userID:SessionID" (in the case of an assumed role) or just a "userID"
|
||||
// (in the case of an IAM user).
|
||||
EntityId: strings.Split(callerIdentity.UserId, ":")[0], |
||||
AccountId: callerIdentity.Account, |
||||
} |
||||
clientArn := entity.CanonicalArn() |
||||
|
||||
// Fetch the IAM Role or IAM User, if configured.
|
||||
// This requires the token to contain a signed iam:GetRole or iam:GetUser request.
|
||||
if a.config.EnableIAMEntityDetails { |
||||
iamReq, err := token.GetEntityRequest() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if a.config.ServerIDHeaderValue != "" { |
||||
err := validateHeaderValue(iamReq.Header, a.config.ServerIDHeaderName, a.config.ServerIDHeaderValue) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
iamEntityDetails, err := a.submitGetIAMEntityRequest(ctx, iamReq, token.entityRequestType) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// Only the CallerIdentity response is a guarantee of the client's identity.
|
||||
// The role/user details must have a unique id match to the CallerIdentity before use.
|
||||
if iamEntityDetails.EntityId() != identityDetails.EntityId { |
||||
return nil, fmt.Errorf("unique id mismatch in login token") |
||||
} |
||||
|
||||
// Use the full ARN with path from the Role/User details
|
||||
clientArn = iamEntityDetails.EntityArn() |
||||
identityDetails.EntityPath = iamEntityDetails.EntityPath() |
||||
identityDetails.EntityTags = iamEntityDetails.EntityTags() |
||||
} |
||||
|
||||
if err := a.validateIdentity(clientArn); err != nil { |
||||
return nil, err |
||||
} |
||||
return identityDetails, nil |
||||
} |
||||
|
||||
// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1321-L1361
|
||||
func (a *Authenticator) validateIdentity(clientArn string) error { |
||||
if stringslice.Contains(a.config.BoundIAMPrincipalARNs, clientArn) { |
||||
// Matches one of BoundIAMPrincipalARNs, so it is trusted
|
||||
return nil |
||||
} |
||||
if a.config.EnableIAMEntityDetails { |
||||
for _, principalArn := range a.config.BoundIAMPrincipalARNs { |
||||
if strings.HasSuffix(principalArn, "*") && lib.GlobbedStringsMatch(principalArn, clientArn) { |
||||
// Wildcard match, so it is trusted
|
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
return fmt.Errorf("IAM principal %s is not trusted", clientArn) |
||||
} |
||||
|
||||
func (a *Authenticator) submitCallerIdentityRequest(ctx context.Context, req *http.Request) (*responses.GetCallerIdentityResult, error) { |
||||
responseBody, err := a.submitRequest(ctx, req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
callerIdentityResponse, err := parseGetCallerIdentityResponse(responseBody) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error parsing STS response") |
||||
} |
||||
|
||||
if n := len(callerIdentityResponse.GetCallerIdentityResult); n != 1 { |
||||
return nil, fmt.Errorf("received %d identities in STS response but expected 1", n) |
||||
} |
||||
return &callerIdentityResponse.GetCallerIdentityResult[0], nil |
||||
} |
||||
|
||||
func (a *Authenticator) submitGetIAMEntityRequest(ctx context.Context, req *http.Request, reqType string) (responses.IAMEntity, error) { |
||||
responseBody, err := a.submitRequest(ctx, req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
iamResponse, err := parseGetIAMEntityResponse(responseBody, reqType) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("error parsing IAM response: %s", err) |
||||
} |
||||
return iamResponse, nil |
||||
|
||||
} |
||||
|
||||
// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1636
|
||||
func (a *Authenticator) submitRequest(ctx context.Context, req *http.Request) (string, error) { |
||||
retryableReq, err := retryablehttp.FromRequest(req) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
retryableReq = retryableReq.WithContext(ctx) |
||||
client := cleanhttp.DefaultClient() |
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error { |
||||
return http.ErrUseLastResponse |
||||
} |
||||
retryingClient := &retryablehttp.Client{ |
||||
HTTPClient: client, |
||||
RetryWaitMin: retryWaitMin, |
||||
RetryWaitMax: retryWaitMax, |
||||
RetryMax: a.config.MaxRetries, |
||||
CheckRetry: retryablehttp.DefaultRetryPolicy, |
||||
Backoff: retryablehttp.DefaultBackoff, |
||||
} |
||||
|
||||
response, err := retryingClient.Do(retryableReq) |
||||
if err != nil { |
||||
return "", fmt.Errorf("error making request: %w", err) |
||||
} |
||||
if response != nil { |
||||
defer response.Body.Close() |
||||
} |
||||
// Validate that the response type is XML
|
||||
if ct := response.Header.Get("Content-Type"); ct != "text/xml" { |
||||
return "", fmt.Errorf("response body is invalid") |
||||
} |
||||
|
||||
// we check for status code afterwards to also print out response body
|
||||
responseBody, err := ioutil.ReadAll(response.Body) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
if response.StatusCode != 200 { |
||||
return "", fmt.Errorf("received error code %d: %s", response.StatusCode, string(responseBody)) |
||||
} |
||||
return string(responseBody), nil |
||||
|
||||
} |
||||
|
||||
// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1625-L1634
|
||||
func parseGetCallerIdentityResponse(response string) (responses.GetCallerIdentityResponse, error) { |
||||
result := responses.GetCallerIdentityResponse{} |
||||
response = strings.TrimSpace(response) |
||||
if !strings.HasPrefix(response, "<GetCallerIdentityResponse") && !strings.HasPrefix(response, "<?xml") { |
||||
return result, fmt.Errorf("body of GetCallerIdentity is invalid") |
||||
} |
||||
decoder := xml.NewDecoder(strings.NewReader(response)) |
||||
err := decoder.Decode(&result) |
||||
return result, err |
||||
} |
||||
|
||||
func parseGetIAMEntityResponse(response string, reqType string) (responses.IAMEntity, error) { |
||||
if !strings.HasPrefix(response, "<GetRoleResponse") && |
||||
!strings.HasPrefix(response, "<GetUserResponse") && |
||||
!strings.HasPrefix(response, "<?xml") { |
||||
return nil, fmt.Errorf("body of GetRole or GetUser is invalid") |
||||
} |
||||
|
||||
decoder := xml.NewDecoder(strings.NewReader(response)) |
||||
|
||||
switch reqType { |
||||
case "GetRole": |
||||
result := &responses.GetRoleResponse{} |
||||
err := decoder.Decode(&result) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if n := len(result.GetRoleResult); n != 1 { |
||||
return nil, fmt.Errorf("received %d identities in GetRole response but expected 1", n) |
||||
} |
||||
return &result.GetRoleResult[0].Role, nil |
||||
case "GetUser": |
||||
result := &responses.GetUserResponse{} |
||||
err := decoder.Decode(&result) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if n := len(result.GetUserResult); n != 1 { |
||||
return nil, fmt.Errorf("received %d identities in GetUser response but expected 1", n) |
||||
} |
||||
return &result.GetUserResult[0].User, nil |
||||
} |
||||
return nil, fmt.Errorf("invalid %s request: %s", reqType, response) |
||||
} |
||||
|
||||
// https://github.com/hashicorp/vault/blob/b17e3256dde937a6248c9a2fa56206aac93d07de/builtin/credential/aws/path_login.go#L1532
|
||||
func validateHeaderValue(headers http.Header, headerName string, requiredHeaderValue string) error { |
||||
providedValue := "" |
||||
for k, v := range headers { |
||||
if strings.EqualFold(headerName, k) { |
||||
providedValue = strings.Join(v, ",") |
||||
break |
||||
} |
||||
} |
||||
if providedValue == "" { |
||||
return fmt.Errorf("missing header %q", headerName) |
||||
} |
||||
|
||||
// NOT doing a constant time compare here since the value is NOT intended to be secret
|
||||
if providedValue != requiredHeaderValue { |
||||
return fmt.Errorf("expected %q but got %q", requiredHeaderValue, providedValue) |
||||
} |
||||
|
||||
if authzHeaders, ok := headers["Authorization"]; ok { |
||||
// authzHeader looks like AWS4-HMAC-SHA256 Credential=AKI..., SignedHeaders=host;x-amz-date;x-vault-awsiam-id, Signature=...
|
||||
// We need to extract out the SignedHeaders
|
||||
re := regexp.MustCompile(".*SignedHeaders=([^,]+)") |
||||
authzHeader := strings.Join(authzHeaders, ",") |
||||
matches := re.FindSubmatch([]byte(authzHeader)) |
||||
if len(matches) < 1 { |
||||
return fmt.Errorf("server id header wasn't signed") |
||||
} |
||||
if len(matches) > 2 { |
||||
return fmt.Errorf("found multiple SignedHeaders components") |
||||
} |
||||
signedHeaders := string(matches[1]) |
||||
return ensureHeaderIsSigned(signedHeaders, headerName) |
||||
} |
||||
// NOTE: If we support GET requests, then we need to parse the X-Amz-SignedHeaders
|
||||
// argument out of the query string and search in there for the header value
|
||||
return fmt.Errorf("missing Authorization header") |
||||
} |
||||
|
||||
func ensureHeaderIsSigned(signedHeaders, headerToSign string) error { |
||||
// Not doing a constant time compare here, the values aren't secret
|
||||
for _, header := range strings.Split(signedHeaders, ";") { |
||||
if header == strings.ToLower(headerToSign) { |
||||
return nil |
||||
} |
||||
} |
||||
return fmt.Errorf("header wasn't signed") |
||||
} |
@ -1,124 +0,0 @@
|
||||
package iamauth |
||||
|
||||
import ( |
||||
"context" |
||||
"encoding/json" |
||||
"testing" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws/credentials" |
||||
"github.com/hashicorp/consul/internal/iamauth/iamauthtest" |
||||
"github.com/hashicorp/consul/internal/iamauth/responses" |
||||
"github.com/hashicorp/consul/internal/iamauth/responsestest" |
||||
"github.com/hashicorp/go-hclog" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestValidateLogin(t *testing.T) { |
||||
f := iamauthtest.MakeFixture() |
||||
|
||||
var ( |
||||
serverForRoleMismatchedIds = &iamauthtest.Server{ |
||||
GetCallerIdentityResponse: f.ServerForRole.GetCallerIdentityResponse, |
||||
GetRoleResponse: responsestest.MakeGetRoleResponse(f.RoleARN, "AAAAsomenonmatchingid", responses.Tags{}), |
||||
} |
||||
serverForUserMismatchedIds = &iamauthtest.Server{ |
||||
GetCallerIdentityResponse: f.ServerForUser.GetCallerIdentityResponse, |
||||
GetUserResponse: responsestest.MakeGetUserResponse(f.UserARN, "AAAAsomenonmatchingid", responses.Tags{}), |
||||
} |
||||
) |
||||
|
||||
cases := map[string]struct { |
||||
config *Config |
||||
server *iamauthtest.Server |
||||
expIdent *IdentityDetails |
||||
expError string |
||||
}{ |
||||
"no bound principals": { |
||||
expError: "not trusted", |
||||
server: f.ServerForRole, |
||||
config: &Config{}, |
||||
}, |
||||
"no matching principal": { |
||||
expError: "not trusted", |
||||
server: f.ServerForUser, |
||||
config: &Config{ |
||||
BoundIAMPrincipalARNs: []string{ |
||||
"arn:aws:iam::1234567890:user/some-other-role", |
||||
"arn:aws:iam::1234567890:user/some-other-user", |
||||
}, |
||||
}, |
||||
}, |
||||
"mismatched server id header": { |
||||
expError: `expected "some-non-matching-value" but got "server.id.example.com"`, |
||||
server: f.ServerForRole, |
||||
config: &Config{ |
||||
BoundIAMPrincipalARNs: []string{f.CanonicalRoleARN}, |
||||
ServerIDHeaderValue: "some-non-matching-value", |
||||
ServerIDHeaderName: "X-Test-ServerID", |
||||
}, |
||||
}, |
||||
"role unique id mismatch": { |
||||
expError: "unique id mismatch in login token", |
||||
// The RoleId in the GetRole response must match the UserId in the GetCallerIdentity response
|
||||
// during login. If not, the RoleId cannot be used.
|
||||
server: serverForRoleMismatchedIds, |
||||
config: &Config{ |
||||
BoundIAMPrincipalARNs: []string{f.RoleARN}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
}, |
||||
"user unique id mismatch": { |
||||
expError: "unique id mismatch in login token", |
||||
server: serverForUserMismatchedIds, |
||||
config: &Config{ |
||||
BoundIAMPrincipalARNs: []string{f.UserARN}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
}, |
||||
} |
||||
logger := hclog.New(nil) |
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
fakeAws := iamauthtest.NewTestServer(t, c.server) |
||||
|
||||
c.config.STSEndpoint = fakeAws.URL + "/sts" |
||||
c.config.IAMEndpoint = fakeAws.URL + "/iam" |
||||
setTestHeaderNames(c.config) |
||||
|
||||
// This bypasses NewAuthenticator, which bypasses config.Validate().
|
||||
auth := &Authenticator{config: c.config, logger: logger} |
||||
|
||||
loginInput := &LoginInput{ |
||||
Creds: credentials.NewStaticCredentials("fake", "fake", ""), |
||||
IncludeIAMEntity: c.config.EnableIAMEntityDetails, |
||||
STSEndpoint: c.config.STSEndpoint, |
||||
STSRegion: "fake-region", |
||||
Logger: logger, |
||||
ServerIDHeaderValue: "server.id.example.com", |
||||
} |
||||
setLoginInputHeaderNames(loginInput) |
||||
loginData, err := GenerateLoginData(loginInput) |
||||
require.NoError(t, err) |
||||
loginBytes, err := json.Marshal(loginData) |
||||
require.NoError(t, err) |
||||
|
||||
ident, err := auth.ValidateLogin(context.Background(), string(loginBytes)) |
||||
if c.expError != "" { |
||||
require.Error(t, err) |
||||
require.Contains(t, err.Error(), c.expError) |
||||
require.Nil(t, ident) |
||||
} else { |
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expIdent, ident) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func setLoginInputHeaderNames(in *LoginInput) { |
||||
in.ServerIDHeaderName = "X-Test-ServerID" |
||||
in.GetEntityMethodHeader = "X-Test-Method" |
||||
in.GetEntityURLHeader = "X-Test-URL" |
||||
in.GetEntityHeadersHeader = "X-Test-Headers" |
||||
in.GetEntityBodyHeader = "X-Test-Body" |
||||
} |
@ -1,80 +0,0 @@
|
||||
package iamauth |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
|
||||
awsArn "github.com/aws/aws-sdk-go/aws/arn" |
||||
) |
||||
|
||||
type Config struct { |
||||
BoundIAMPrincipalARNs []string |
||||
EnableIAMEntityDetails bool |
||||
IAMEntityTags []string |
||||
ServerIDHeaderValue string |
||||
MaxRetries int |
||||
IAMEndpoint string |
||||
STSEndpoint string |
||||
AllowedSTSHeaderValues []string |
||||
|
||||
// Customizable header names
|
||||
ServerIDHeaderName string |
||||
GetEntityMethodHeader string |
||||
GetEntityURLHeader string |
||||
GetEntityHeadersHeader string |
||||
GetEntityBodyHeader string |
||||
} |
||||
|
||||
func (c *Config) Validate() error { |
||||
if len(c.BoundIAMPrincipalARNs) == 0 { |
||||
return fmt.Errorf("BoundIAMPrincipalARNs is required and must have at least 1 entry") |
||||
} |
||||
|
||||
for _, arn := range c.BoundIAMPrincipalARNs { |
||||
if n := strings.Count(arn, "*"); n > 0 { |
||||
if !c.EnableIAMEntityDetails { |
||||
return fmt.Errorf("Must set EnableIAMEntityDetails=true to use wildcards in BoundIAMPrincipalARNs") |
||||
} |
||||
if n != 1 || !strings.HasSuffix(arn, "*") { |
||||
return fmt.Errorf("Only one wildcard is allowed at the end of the bound IAM principal ARN") |
||||
} |
||||
} |
||||
|
||||
if parsed, err := awsArn.Parse(arn); err != nil { |
||||
return fmt.Errorf("Invalid principal ARN: %q", arn) |
||||
} else if parsed.Service != "iam" && parsed.Service != "sts" { |
||||
return fmt.Errorf("Invalid principal ARN: %q", arn) |
||||
} |
||||
} |
||||
|
||||
if len(c.IAMEntityTags) > 0 && !c.EnableIAMEntityDetails { |
||||
return fmt.Errorf("Must set EnableIAMEntityDetails=true to use IAMUserTags") |
||||
} |
||||
|
||||
// If server id header checking is enabled, we need the header name.
|
||||
if c.ServerIDHeaderValue != "" && c.ServerIDHeaderName == "" { |
||||
return fmt.Errorf("Must set ServerIDHeaderName to use a server ID value") |
||||
} |
||||
|
||||
if c.EnableIAMEntityDetails && (c.GetEntityBodyHeader == "" || |
||||
c.GetEntityHeadersHeader == "" || |
||||
c.GetEntityMethodHeader == "" || |
||||
c.GetEntityURLHeader == "") { |
||||
return fmt.Errorf("Must set all of GetEntityMethodHeader, GetEntityURLHeader, " + |
||||
"GetEntityHeadersHeader, and GetEntityBodyHeader when EnableIAMEntityDetails=true") |
||||
} |
||||
|
||||
if c.STSEndpoint != "" { |
||||
if _, err := parseUrl(c.STSEndpoint); err != nil { |
||||
return fmt.Errorf("STSEndpoint is invalid: %s", err) |
||||
} |
||||
} |
||||
|
||||
if c.IAMEndpoint != "" { |
||||
if _, err := parseUrl(c.IAMEndpoint); err != nil { |
||||
return fmt.Errorf("IAMEndpoint is invalid: %s", err) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -1,150 +0,0 @@
|
||||
package iamauth |
||||
|
||||
import ( |
||||
"fmt" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestConfigValidate(t *testing.T) { |
||||
principalArn := "arn:aws:iam::000000000000:role/my-role" |
||||
|
||||
cases := map[string]struct { |
||||
expError string |
||||
configs []Config |
||||
|
||||
includeHeaderNames bool |
||||
}{ |
||||
"bound iam principals are required": { |
||||
expError: "BoundIAMPrincipalARNs is required and must have at least 1 entry", |
||||
configs: []Config{ |
||||
{BoundIAMPrincipalARNs: nil}, |
||||
{BoundIAMPrincipalARNs: []string{}}, |
||||
}, |
||||
}, |
||||
"entity tags require entity details": { |
||||
expError: "Must set EnableIAMEntityDetails=true to use IAMUserTags", |
||||
configs: []Config{ |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
EnableIAMEntityDetails: false, |
||||
IAMEntityTags: []string{"some-tag"}, |
||||
}, |
||||
}, |
||||
}, |
||||
"entity details require all entity header names": { |
||||
expError: "Must set all of GetEntityMethodHeader, GetEntityURLHeader, " + |
||||
"GetEntityHeadersHeader, and GetEntityBodyHeader when EnableIAMEntityDetails=true", |
||||
configs: []Config{ |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
EnableIAMEntityDetails: true, |
||||
GetEntityBodyHeader: "X-Test-Header", |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
EnableIAMEntityDetails: true, |
||||
GetEntityHeadersHeader: "X-Test-Header", |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
EnableIAMEntityDetails: true, |
||||
GetEntityURLHeader: "X-Test-Header", |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
EnableIAMEntityDetails: true, |
||||
GetEntityMethodHeader: "X-Test-Header", |
||||
}, |
||||
}, |
||||
}, |
||||
"wildcard principals require entity details": { |
||||
expError: "Must set EnableIAMEntityDetails=true to use wildcards in BoundIAMPrincipalARNs", |
||||
configs: []Config{ |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*"}}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/path/*"}}, |
||||
}, |
||||
}, |
||||
"only one wildcard suffix is allowed": { |
||||
expError: "Only one wildcard is allowed at the end of the bound IAM principal ARN", |
||||
configs: []Config{ |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/**"}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*/*"}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*/path"}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*/path/*"}, |
||||
EnableIAMEntityDetails: true, |
||||
}, |
||||
}, |
||||
}, |
||||
"invalid principal arns are disallowed": { |
||||
expError: fmt.Sprintf("Invalid principal ARN"), |
||||
configs: []Config{ |
||||
{BoundIAMPrincipalARNs: []string{""}}, |
||||
{BoundIAMPrincipalARNs: []string{" "}}, |
||||
{BoundIAMPrincipalARNs: []string{"*"}, EnableIAMEntityDetails: true}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam:role/my-role"}}, |
||||
}, |
||||
}, |
||||
"valid principal arns are allowed": { |
||||
includeHeaderNames: true, |
||||
configs: []Config{ |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:sts::000000000000:assumed-role/my-role/some-session-name"}}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:user/my-user"}}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/my-role"}}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:*"}, EnableIAMEntityDetails: true}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/*"}, EnableIAMEntityDetails: true}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:role/path/*"}, EnableIAMEntityDetails: true}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:user/*"}, EnableIAMEntityDetails: true}, |
||||
{BoundIAMPrincipalARNs: []string{"arn:aws:iam::000000000000:user/path/*"}, EnableIAMEntityDetails: true}, |
||||
}, |
||||
}, |
||||
"server id header value requires service id header name": { |
||||
expError: "Must set ServerIDHeaderName to use a server ID value", |
||||
configs: []Config{ |
||||
{ |
||||
BoundIAMPrincipalARNs: []string{principalArn}, |
||||
ServerIDHeaderValue: "consul.test.example.com", |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
for _, conf := range c.configs { |
||||
if c.includeHeaderNames { |
||||
setTestHeaderNames(&conf) |
||||
} |
||||
err := conf.Validate() |
||||
if c.expError != "" { |
||||
require.Error(t, err) |
||||
require.Contains(t, err.Error(), c.expError) |
||||
} else { |
||||
require.NoError(t, err) |
||||
} |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func setTestHeaderNames(conf *Config) { |
||||
conf.GetEntityMethodHeader = "X-Test-Method" |
||||
conf.GetEntityURLHeader = "X-Test-URL" |
||||
conf.GetEntityHeadersHeader = "X-Test-Headers" |
||||
conf.GetEntityBodyHeader = "X-Test-Body" |
||||
} |
@ -1,187 +0,0 @@
|
||||
package iamauthtest |
||||
|
||||
import ( |
||||
"encoding/xml" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"sort" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/hashicorp/consul/internal/iamauth/responses" |
||||
"github.com/hashicorp/consul/internal/iamauth/responsestest" |
||||
) |
||||
|
||||
// NewTestServer returns a fake AWS API server for local tests:
|
||||
// It supports the following paths:
|
||||
// /sts returns STS API responses
|
||||
// /iam returns IAM API responses
|
||||
func NewTestServer(t *testing.T, s *Server) *httptest.Server { |
||||
server := httptest.NewUnstartedServer(s) |
||||
t.Cleanup(server.Close) |
||||
server.Start() |
||||
return server |
||||
} |
||||
|
||||
// Server contains configuration for the fake AWS API server.
|
||||
type Server struct { |
||||
GetCallerIdentityResponse responses.GetCallerIdentityResponse |
||||
GetRoleResponse responses.GetRoleResponse |
||||
GetUserResponse responses.GetUserResponse |
||||
} |
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
||||
if r.Method != "POST" { |
||||
writeError(w, http.StatusBadRequest, r) |
||||
return |
||||
} |
||||
|
||||
switch { |
||||
case strings.HasPrefix(r.URL.Path, "/sts"): |
||||
writeXML(w, s.GetCallerIdentityResponse) |
||||
case strings.HasPrefix(r.URL.Path, "/iam"): |
||||
if bodyBytes, err := io.ReadAll(r.Body); err == nil { |
||||
body := string(bodyBytes) |
||||
switch { |
||||
case strings.Contains(body, "Action=GetRole"): |
||||
writeXML(w, s.GetRoleResponse) |
||||
return |
||||
case strings.Contains(body, "Action=GetUser"): |
||||
writeXML(w, s.GetUserResponse) |
||||
return |
||||
} |
||||
} |
||||
writeError(w, http.StatusBadRequest, r) |
||||
default: |
||||
writeError(w, http.StatusNotFound, r) |
||||
} |
||||
} |
||||
|
||||
func writeXML(w http.ResponseWriter, val interface{}) { |
||||
str, err := xml.MarshalIndent(val, "", " ") |
||||
if err != nil { |
||||
w.WriteHeader(http.StatusInternalServerError) |
||||
fmt.Fprint(w, err.Error()) |
||||
return |
||||
} |
||||
w.Header().Add("Content-Type", "text/xml") |
||||
w.WriteHeader(http.StatusOK) |
||||
fmt.Fprint(w, string(str)) |
||||
} |
||||
|
||||
func writeError(w http.ResponseWriter, code int, r *http.Request) { |
||||
w.WriteHeader(code) |
||||
msg := fmt.Sprintf("%s %s", r.Method, r.URL) |
||||
fmt.Fprintf(w, `<ErrorResponse xmlns="https://fakeaws/"> |
||||
<Error> |
||||
<Message>Fake AWS Server Error: %s</Message> |
||||
</Error> |
||||
</ErrorResponse>`, msg) |
||||
} |
||||
|
||||
type Fixture struct { |
||||
AssumedRoleARN string |
||||
CanonicalRoleARN string |
||||
RoleARN string |
||||
RoleARNWildcard string |
||||
RoleName string |
||||
RolePath string |
||||
RoleTags map[string]string |
||||
|
||||
EntityID string |
||||
EntityIDWithSession string |
||||
AccountID string |
||||
|
||||
UserARN string |
||||
UserARNWildcard string |
||||
UserName string |
||||
UserPath string |
||||
UserTags map[string]string |
||||
|
||||
ServerForRole *Server |
||||
ServerForUser *Server |
||||
} |
||||
|
||||
func MakeFixture() Fixture { |
||||
f := Fixture{ |
||||
AssumedRoleARN: "arn:aws:sts::1234567890:assumed-role/my-role/some-session", |
||||
CanonicalRoleARN: "arn:aws:iam::1234567890:role/my-role", |
||||
RoleARN: "arn:aws:iam::1234567890:role/some/path/my-role", |
||||
RoleARNWildcard: "arn:aws:iam::1234567890:role/some/path/*", |
||||
RoleName: "my-role", |
||||
RolePath: "some/path", |
||||
RoleTags: map[string]string{ |
||||
"service-name": "my-service", |
||||
"env": "my-env", |
||||
}, |
||||
|
||||
EntityID: "AAAsomeuniqueid", |
||||
EntityIDWithSession: "AAAsomeuniqueid:some-session", |
||||
AccountID: "1234567890", |
||||
|
||||
UserARN: "arn:aws:iam::1234567890:user/my-user", |
||||
UserARNWildcard: "arn:aws:iam::1234567890:user/*", |
||||
UserName: "my-user", |
||||
UserPath: "", |
||||
UserTags: map[string]string{"user-group": "my-group"}, |
||||
} |
||||
|
||||
f.ServerForRole = &Server{ |
||||
GetCallerIdentityResponse: responsestest.MakeGetCallerIdentityResponse( |
||||
f.AssumedRoleARN, f.EntityIDWithSession, f.AccountID, |
||||
), |
||||
GetRoleResponse: responsestest.MakeGetRoleResponse( |
||||
f.RoleARN, f.EntityID, toTags(f.RoleTags), |
||||
), |
||||
} |
||||
|
||||
f.ServerForUser = &Server{ |
||||
GetCallerIdentityResponse: responsestest.MakeGetCallerIdentityResponse( |
||||
f.UserARN, f.EntityID, f.AccountID, |
||||
), |
||||
GetUserResponse: responsestest.MakeGetUserResponse( |
||||
f.UserARN, f.EntityID, toTags(f.UserTags), |
||||
), |
||||
} |
||||
|
||||
return f |
||||
} |
||||
|
||||
func (f *Fixture) RoleTagKeys() []string { return keys(f.RoleTags) } |
||||
func (f *Fixture) UserTagKeys() []string { return keys(f.UserTags) } |
||||
func (f *Fixture) RoleTagValues() []string { return values(f.RoleTags) } |
||||
func (f *Fixture) UserTagValues() []string { return values(f.UserTags) } |
||||
|
||||
// toTags converts the map to a slice of responses.Tag
|
||||
func toTags(tags map[string]string) responses.Tags { |
||||
members := []responses.TagMember{} |
||||
for k, v := range tags { |
||||
members = append(members, responses.TagMember{ |
||||
Key: k, |
||||
Value: v, |
||||
}) |
||||
} |
||||
return responses.Tags{Members: members} |
||||
|
||||
} |
||||
|
||||
// keys returns the keys in sorted order
|
||||
func keys(tags map[string]string) []string { |
||||
result := []string{} |
||||
for k := range tags { |
||||
result = append(result, k) |
||||
} |
||||
sort.Strings(result) |
||||
return result |
||||
} |
||||
|
||||
// values returns values in tags, ordered by sorted keys
|
||||
func values(tags map[string]string) []string { |
||||
result := []string{} |
||||
for _, k := range keys(tags) { // ensures sorted by key
|
||||
result = append(result, tags[k]) |
||||
} |
||||
return result |
||||
} |
@ -1,94 +0,0 @@
|
||||
package responses |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strings" |
||||
) |
||||
|
||||
// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1722-L1744
|
||||
type ParsedArn struct { |
||||
Partition string |
||||
AccountNumber string |
||||
Type string |
||||
Path string |
||||
FriendlyName string |
||||
SessionInfo string |
||||
} |
||||
|
||||
// https://github.com/hashicorp/vault/blob/ba533d006f2244103648785ebfe8a9a9763d2b6e/builtin/credential/aws/path_login.go#L1482-L1530
|
||||
// However, instance profiles are not support in Consul.
|
||||
func ParseArn(iamArn string) (*ParsedArn, error) { |
||||
// iamArn should look like one of the following:
|
||||
// 1. arn:aws:iam::<account_id>:<entity_type>/<UserName>
|
||||
// 2. arn:aws:sts::<account_id>:assumed-role/<RoleName>/<RoleSessionName>
|
||||
// if we get something like 2, then we want to transform that back to what
|
||||
// most people would expect, which is arn:aws:iam::<account_id>:role/<RoleName>
|
||||
var entity ParsedArn |
||||
fullParts := strings.Split(iamArn, ":") |
||||
if len(fullParts) != 6 { |
||||
return nil, fmt.Errorf("unrecognized arn: contains %d colon-separated parts, expected 6", len(fullParts)) |
||||
} |
||||
if fullParts[0] != "arn" { |
||||
return nil, fmt.Errorf("unrecognized arn: does not begin with \"arn:\"") |
||||
} |
||||
// normally aws, but could be aws-cn or aws-us-gov
|
||||
entity.Partition = fullParts[1] |
||||
if entity.Partition == "" { |
||||
return nil, fmt.Errorf("unrecognized arn: %q is missing the partition", iamArn) |
||||
} |
||||
if fullParts[2] != "iam" && fullParts[2] != "sts" { |
||||
return nil, fmt.Errorf("unrecognized service: %v, not one of iam or sts", fullParts[2]) |
||||
} |
||||
// fullParts[3] is the region, which doesn't matter for AWS IAM entities
|
||||
entity.AccountNumber = fullParts[4] |
||||
if entity.AccountNumber == "" { |
||||
return nil, fmt.Errorf("unrecognized arn: %q is missing the account number", iamArn) |
||||
} |
||||
// fullParts[5] would now be something like user/<UserName> or assumed-role/<RoleName>/<RoleSessionName>
|
||||
parts := strings.Split(fullParts[5], "/") |
||||
if len(parts) < 2 { |
||||
return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 2 slash-separated parts", fullParts[5]) |
||||
} |
||||
entity.Type = parts[0] |
||||
entity.Path = strings.Join(parts[1:len(parts)-1], "/") |
||||
entity.FriendlyName = parts[len(parts)-1] |
||||
// now, entity.FriendlyName should either be <UserName> or <RoleName>
|
||||
switch entity.Type { |
||||
case "assumed-role": |
||||
// Check for three parts for assumed role ARNs
|
||||
if len(parts) < 3 { |
||||
return nil, fmt.Errorf("unrecognized arn: %q contains fewer than 3 slash-separated parts", fullParts[5]) |
||||
} |
||||
// Assumed roles don't have paths and have a slightly different format
|
||||
// parts[2] is <RoleSessionName>
|
||||
entity.Path = "" |
||||
entity.FriendlyName = parts[1] |
||||
entity.SessionInfo = parts[2] |
||||
case "user": |
||||
case "role": |
||||
// case "instance-profile":
|
||||
default: |
||||
return nil, fmt.Errorf("unrecognized principal type: %q", entity.Type) |
||||
} |
||||
|
||||
if entity.FriendlyName == "" { |
||||
return nil, fmt.Errorf("unrecognized arn: %q is missing the resource name", iamArn) |
||||
} |
||||
|
||||
return &entity, nil |
||||
} |
||||
|
||||
// CanonicalArn returns the canonical ARN for referring to an IAM entity
|
||||
func (p *ParsedArn) CanonicalArn() string { |
||||
entityType := p.Type |
||||
// canonicalize "assumed-role" into "role"
|
||||
if entityType == "assumed-role" { |
||||
entityType = "role" |
||||
} |
||||
// Annoyingly, the assumed-role entity type doesn't have the Path of the role which was assumed
|
||||
// So, we "canonicalize" it by just completely dropping the path. The other option would be to
|
||||
// make an AWS API call to look up the role by FriendlyName, which introduces more complexity to
|
||||
// code and test, and it also breaks backwards compatibility in an area where we would really want
|
||||
// it
|
||||
return fmt.Sprintf("arn:%s:iam::%s:%s/%s", p.Partition, p.AccountNumber, entityType, p.FriendlyName) |
||||
} |
@ -1,96 +0,0 @@
|
||||
package responses |
||||
|
||||
import "encoding/xml" |
||||
|
||||
type GetCallerIdentityResponse struct { |
||||
XMLName xml.Name `xml:"GetCallerIdentityResponse"` |
||||
GetCallerIdentityResult []GetCallerIdentityResult `xml:"GetCallerIdentityResult"` |
||||
ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` |
||||
} |
||||
|
||||
type GetCallerIdentityResult struct { |
||||
Arn string `xml:"Arn"` |
||||
UserId string `xml:"UserId"` |
||||
Account string `xml:"Account"` |
||||
} |
||||
|
||||
type ResponseMetadata struct { |
||||
RequestId string `xml:"RequestId"` |
||||
} |
||||
|
||||
// IAMEntity is an interface for getting details from an IAM Role or User.
|
||||
type IAMEntity interface { |
||||
EntityPath() string |
||||
EntityArn() string |
||||
EntityName() string |
||||
EntityId() string |
||||
EntityTags() map[string]string |
||||
} |
||||
|
||||
var _ IAMEntity = (*Role)(nil) |
||||
var _ IAMEntity = (*User)(nil) |
||||
|
||||
type GetRoleResponse struct { |
||||
XMLName xml.Name `xml:"GetRoleResponse"` |
||||
GetRoleResult []GetRoleResult `xml:"GetRoleResult"` |
||||
ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` |
||||
} |
||||
|
||||
type GetRoleResult struct { |
||||
Role Role `xml:"Role"` |
||||
} |
||||
|
||||
type Role struct { |
||||
Arn string `xml:"Arn"` |
||||
Path string `xml:"Path"` |
||||
RoleId string `xml:"RoleId"` |
||||
RoleName string `xml:"RoleName"` |
||||
Tags Tags `xml:"Tags"` |
||||
} |
||||
|
||||
func (r *Role) EntityPath() string { return r.Path } |
||||
func (r *Role) EntityArn() string { return r.Arn } |
||||
func (r *Role) EntityName() string { return r.RoleName } |
||||
func (r *Role) EntityId() string { return r.RoleId } |
||||
func (r *Role) EntityTags() map[string]string { return tagsToMap(r.Tags) } |
||||
|
||||
type GetUserResponse struct { |
||||
XMLName xml.Name `xml:"GetUserResponse"` |
||||
GetUserResult []GetUserResult `xml:"GetUserResult"` |
||||
ResponseMetadata []ResponseMetadata `xml:"ResponseMetadata"` |
||||
} |
||||
|
||||
type GetUserResult struct { |
||||
User User `xml:"User"` |
||||
} |
||||
|
||||
type User struct { |
||||
Arn string `xml:"Arn"` |
||||
Path string `xml:"Path"` |
||||
UserId string `xml:"UserId"` |
||||
UserName string `xml:"UserName"` |
||||
Tags Tags `xml:"Tags"` |
||||
} |
||||
|
||||
func (u *User) EntityPath() string { return u.Path } |
||||
func (u *User) EntityArn() string { return u.Arn } |
||||
func (u *User) EntityName() string { return u.UserName } |
||||
func (u *User) EntityId() string { return u.UserId } |
||||
func (u *User) EntityTags() map[string]string { return tagsToMap(u.Tags) } |
||||
|
||||
type Tags struct { |
||||
Members []TagMember `xml:"member"` |
||||
} |
||||
|
||||
type TagMember struct { |
||||
Key string `xml:"Key"` |
||||
Value string `xml:"Value"` |
||||
} |
||||
|
||||
func tagsToMap(tags Tags) map[string]string { |
||||
result := map[string]string{} |
||||
for _, tag := range tags.Members { |
||||
result[tag.Key] = tag.Value |
||||
} |
||||
return result |
||||
} |
@ -1,293 +0,0 @@
|
||||
package responses |
||||
|
||||
import ( |
||||
"encoding/xml" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestParseArn(t *testing.T) { |
||||
cases := map[string]struct { |
||||
arn string |
||||
expArn *ParsedArn |
||||
}{ |
||||
"assumed-role": { |
||||
arn: "arn:aws:sts::000000000000:assumed-role/my-role/session-name", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "assumed-role", |
||||
Path: "", |
||||
FriendlyName: "my-role", |
||||
SessionInfo: "session-name", |
||||
}, |
||||
}, |
||||
"role": { |
||||
arn: "arn:aws:iam::000000000000:role/my-role", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "role", |
||||
Path: "", |
||||
FriendlyName: "my-role", |
||||
SessionInfo: "", |
||||
}, |
||||
}, |
||||
"user": { |
||||
arn: "arn:aws:iam::000000000000:user/my-user", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "user", |
||||
Path: "", |
||||
FriendlyName: "my-user", |
||||
SessionInfo: "", |
||||
}, |
||||
}, |
||||
"role with path": { |
||||
arn: "arn:aws:iam::000000000000:role/path/my-role", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "role", |
||||
Path: "path", |
||||
FriendlyName: "my-role", |
||||
SessionInfo: "", |
||||
}, |
||||
}, |
||||
"role with path 2": { |
||||
arn: "arn:aws:iam::000000000000:role/path/to/my-role", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "role", |
||||
Path: "path/to", |
||||
FriendlyName: "my-role", |
||||
SessionInfo: "", |
||||
}, |
||||
}, |
||||
"role with path 3": { |
||||
arn: "arn:aws:iam::000000000000:role/some/path/to/my-role", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "role", |
||||
Path: "some/path/to", |
||||
FriendlyName: "my-role", |
||||
SessionInfo: "", |
||||
}, |
||||
}, |
||||
"user with path": { |
||||
arn: "arn:aws:iam::000000000000:user/path/my-user", |
||||
expArn: &ParsedArn{ |
||||
Partition: "aws", |
||||
AccountNumber: "000000000000", |
||||
Type: "user", |
||||
Path: "path", |
||||
FriendlyName: "my-user", |
||||
SessionInfo: "", |
||||
}, |
||||
}, |
||||
|
||||
// Invalid cases
|
||||
"empty string": {arn: ""}, |
||||
"wildcard": {arn: "*"}, |
||||
"missing prefix": {arn: ":aws:sts::000000000000:assumed-role/my-role/session-name"}, |
||||
"missing partition": {arn: "arn::sts::000000000000:assumed-role/my-role/session-name"}, |
||||
"missing service": {arn: "arn:aws:::000000000000:assumed-role/my-role/session-name"}, |
||||
"missing separator": {arn: "arn:aws:sts:000000000000:assumed-role/my-role/session-name"}, |
||||
"missing account id": {arn: "arn:aws:sts:::assumed-role/my-role/session-name"}, |
||||
"missing resource": {arn: "arn:aws:sts::000000000000:"}, |
||||
"assumed-role missing parts": {arn: "arn:aws:sts::000000000000:assumed-role/my-role"}, |
||||
"role missing parts": {arn: "arn:aws:sts::000000000000:role"}, |
||||
"role missing parts 2": {arn: "arn:aws:sts::000000000000:role/"}, |
||||
"user missing parts": {arn: "arn:aws:sts::000000000000:user"}, |
||||
"user missing parts 2": {arn: "arn:aws:sts::000000000000:user/"}, |
||||
"unsupported service": {arn: "arn:aws:ecs:us-east-1:000000000000:task/my-task/00000000000000000000000000000000"}, |
||||
} |
||||
|
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
parsed, err := ParseArn(c.arn) |
||||
if c.expArn != nil { |
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expArn, parsed) |
||||
} else { |
||||
require.Error(t, err) |
||||
require.Nil(t, parsed) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestCanonicalArn(t *testing.T) { |
||||
cases := map[string]struct { |
||||
arn string |
||||
expArn string |
||||
}{ |
||||
"assumed-role arn": { |
||||
arn: "arn:aws:sts::000000000000:assumed-role/my-role/session-name", |
||||
expArn: "arn:aws:iam::000000000000:role/my-role", |
||||
}, |
||||
"role arn": { |
||||
arn: "arn:aws:iam::000000000000:role/my-role", |
||||
expArn: "arn:aws:iam::000000000000:role/my-role", |
||||
}, |
||||
"role arn with path": { |
||||
arn: "arn:aws:iam::000000000000:role/path/to/my-role", |
||||
expArn: "arn:aws:iam::000000000000:role/my-role", |
||||
}, |
||||
"user arn": { |
||||
arn: "arn:aws:iam::000000000000:user/my-user", |
||||
expArn: "arn:aws:iam::000000000000:user/my-user", |
||||
}, |
||||
"user arn with path": { |
||||
arn: "arn:aws:iam::000000000000:user/path/to/my-user", |
||||
expArn: "arn:aws:iam::000000000000:user/my-user", |
||||
}, |
||||
} |
||||
|
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
parsed, err := ParseArn(c.arn) |
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expArn, parsed.CanonicalArn()) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestUnmarshalXML(t *testing.T) { |
||||
t.Run("user xml", func(t *testing.T) { |
||||
var resp GetUserResponse |
||||
err := xml.Unmarshal([]byte(rawUserXML), &resp) |
||||
require.NoError(t, err) |
||||
require.Equal(t, expectedParsedUserXML, resp) |
||||
}) |
||||
t.Run("role xml", func(t *testing.T) { |
||||
var resp GetRoleResponse |
||||
err := xml.Unmarshal([]byte(rawRoleXML), &resp) |
||||
require.NoError(t, err) |
||||
require.Equal(t, expectedParsedRoleXML, resp) |
||||
}) |
||||
} |
||||
|
||||
var ( |
||||
rawUserXML = `<GetUserResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"> |
||||
<GetUserResult> |
||||
<User> |
||||
<Path>/</Path> |
||||
<Arn>arn:aws:iam::000000000000:user/my-user</Arn> |
||||
<UserName>my-user</UserName> |
||||
<UserId>AIDAexampleuserid</UserId> |
||||
<CreateDate>2021-01-01T00:01:02Z</CreateDate> |
||||
<Tags> |
||||
<member> |
||||
<Value>some-value</Value> |
||||
<Key>some-tag</Key> |
||||
</member> |
||||
<member> |
||||
<Value>another-value</Value> |
||||
<Key>another-tag</Key> |
||||
</member> |
||||
<member> |
||||
<Value>third-value</Value> |
||||
<Key>third-tag</Key> |
||||
</member> |
||||
</Tags> |
||||
</User> |
||||
</GetUserResult> |
||||
<ResponseMetadata> |
||||
<RequestId>11815b96-cb16-4d33-b2cf-0042fa4db4cd</RequestId> |
||||
</ResponseMetadata> |
||||
</GetUserResponse>` |
||||
|
||||
expectedParsedUserXML = GetUserResponse{ |
||||
XMLName: xml.Name{ |
||||
Space: "https://iam.amazonaws.com/doc/2010-05-08/", |
||||
Local: "GetUserResponse", |
||||
}, |
||||
GetUserResult: []GetUserResult{ |
||||
{ |
||||
User: User{ |
||||
Arn: "arn:aws:iam::000000000000:user/my-user", |
||||
Path: "/", |
||||
UserId: "AIDAexampleuserid", |
||||
UserName: "my-user", |
||||
Tags: Tags{ |
||||
Members: []TagMember{ |
||||
{Key: "some-tag", Value: "some-value"}, |
||||
{Key: "another-tag", Value: "another-value"}, |
||||
{Key: "third-tag", Value: "third-value"}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
ResponseMetadata: []ResponseMetadata{ |
||||
{RequestId: "11815b96-cb16-4d33-b2cf-0042fa4db4cd"}, |
||||
}, |
||||
} |
||||
|
||||
rawRoleXML = `<GetRoleResponse xmlns="https://iam.amazonaws.com/doc/2010-05-08/"> |
||||
<GetRoleResult> |
||||
<Role> |
||||
<Path>/</Path> |
||||
<AssumeRolePolicyDocument>some-json-document-that-we-ignore</AssumeRolePolicyDocument> |
||||
<MaxSessionDuration>43200</MaxSessionDuration> |
||||
<RoleId>AROAsomeuniqueid</RoleId> |
||||
<RoleLastUsed> |
||||
<LastUsedDate>2022-01-01T01:02:03Z</LastUsedDate> |
||||
<Region>us-east-1</Region> |
||||
</RoleLastUsed> |
||||
<RoleName>my-role</RoleName> |
||||
<Arn>arn:aws:iam::000000000000:role/my-role</Arn> |
||||
<CreateDate>2020-01-01T00:00:01Z</CreateDate> |
||||
<Tags> |
||||
<member> |
||||
<Value>some-value</Value> |
||||
<Key>some-key</Key> |
||||
</member> |
||||
<member> |
||||
<Value>another-value</Value> |
||||
<Key>another-key</Key> |
||||
</member> |
||||
<member> |
||||
<Value>a-third-value</Value> |
||||
<Key>third-key</Key> |
||||
</member> |
||||
</Tags> |
||||
</Role> |
||||
</GetRoleResult> |
||||
<ResponseMetadata> |
||||
<RequestId>a9866067-c0e5-4b5e-86ba-429c1151e2fb</RequestId> |
||||
</ResponseMetadata> |
||||
</GetRoleResponse>` |
||||
|
||||
expectedParsedRoleXML = GetRoleResponse{ |
||||
XMLName: xml.Name{ |
||||
Space: "https://iam.amazonaws.com/doc/2010-05-08/", |
||||
Local: "GetRoleResponse", |
||||
}, |
||||
GetRoleResult: []GetRoleResult{ |
||||
{ |
||||
Role: Role{ |
||||
Arn: "arn:aws:iam::000000000000:role/my-role", |
||||
Path: "/", |
||||
RoleId: "AROAsomeuniqueid", |
||||
RoleName: "my-role", |
||||
Tags: Tags{ |
||||
Members: []TagMember{ |
||||
{Key: "some-key", Value: "some-value"}, |
||||
{Key: "another-key", Value: "another-value"}, |
||||
{Key: "third-key", Value: "a-third-value"}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
ResponseMetadata: []ResponseMetadata{ |
||||
{RequestId: "a9866067-c0e5-4b5e-86ba-429c1151e2fb"}, |
||||
}, |
||||
} |
||||
) |
@ -1,81 +0,0 @@
|
||||
package responsestest |
||||
|
||||
import ( |
||||
"strings" |
||||
|
||||
"github.com/hashicorp/consul/internal/iamauth/responses" |
||||
) |
||||
|
||||
func MakeGetCallerIdentityResponse(arn, userId, accountId string) responses.GetCallerIdentityResponse { |
||||
// Sanity check the UserId for unit tests.
|
||||
parsed := parseArn(arn) |
||||
switch parsed.Type { |
||||
case "assumed-role": |
||||
if !strings.Contains(userId, ":") { |
||||
panic("UserId for assumed-role in GetCallerIdentity response must be '<uniqueId>:<session>'") |
||||
} |
||||
default: |
||||
if strings.Contains(userId, ":") { |
||||
panic("UserId in GetCallerIdentity must not contain ':'") |
||||
} |
||||
} |
||||
|
||||
return responses.GetCallerIdentityResponse{ |
||||
GetCallerIdentityResult: []responses.GetCallerIdentityResult{ |
||||
{ |
||||
Arn: arn, |
||||
UserId: userId, |
||||
Account: accountId, |
||||
}, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func MakeGetRoleResponse(arn, id string, tags responses.Tags) responses.GetRoleResponse { |
||||
if strings.Contains(id, ":") { |
||||
panic("RoleId in GetRole response must not contain ':'") |
||||
} |
||||
parsed := parseArn(arn) |
||||
return responses.GetRoleResponse{ |
||||
GetRoleResult: []responses.GetRoleResult{ |
||||
{ |
||||
Role: responses.Role{ |
||||
Arn: arn, |
||||
Path: parsed.Path, |
||||
RoleId: id, |
||||
RoleName: parsed.FriendlyName, |
||||
Tags: tags, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func MakeGetUserResponse(arn, id string, tags responses.Tags) responses.GetUserResponse { |
||||
if strings.Contains(id, ":") { |
||||
panic("UserId in GetUser resposne must not contain ':'") |
||||
} |
||||
parsed := parseArn(arn) |
||||
return responses.GetUserResponse{ |
||||
GetUserResult: []responses.GetUserResult{ |
||||
{ |
||||
User: responses.User{ |
||||
Arn: arn, |
||||
Path: parsed.Path, |
||||
UserId: id, |
||||
UserName: parsed.FriendlyName, |
||||
Tags: tags, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
} |
||||
|
||||
func parseArn(arn string) *responses.ParsedArn { |
||||
parsed, err := responses.ParseArn(arn) |
||||
if err != nil { |
||||
// For testing, just fail immediately.
|
||||
panic(err) |
||||
} |
||||
return parsed |
||||
} |
@ -1,403 +0,0 @@
|
||||
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 |
||||
} |
@ -1,483 +0,0 @@
|
||||
package iamauth |
||||
|
||||
import ( |
||||
"net/http" |
||||
"net/url" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestNewBearerToken(t *testing.T) { |
||||
cases := map[string]struct { |
||||
tokenStr string |
||||
config Config |
||||
expToken BearerToken |
||||
expError string |
||||
}{ |
||||
"valid token": { |
||||
tokenStr: validBearerTokenJson, |
||||
expToken: validBearerTokenParsed, |
||||
}, |
||||
"valid token with role": { |
||||
tokenStr: validBearerTokenWithRoleJson, |
||||
config: Config{ |
||||
EnableIAMEntityDetails: true, |
||||
GetEntityMethodHeader: "X-Consul-IAM-GetEntity-Method", |
||||
GetEntityURLHeader: "X-Consul-IAM-GetEntity-URL", |
||||
GetEntityHeadersHeader: "X-Consul-IAM-GetEntity-Headers", |
||||
GetEntityBodyHeader: "X-Consul-IAM-GetEntity-Body", |
||||
STSEndpoint: validBearerTokenParsed.getCallerIdentityURL, |
||||
}, |
||||
expToken: validBearerTokenWithRoleParsed, |
||||
}, |
||||
|
||||
"empty json": { |
||||
tokenStr: `{}`, |
||||
expError: "unexpected end of JSON input", |
||||
}, |
||||
"missing iam_request_method field": { |
||||
tokenStr: tokenJsonMissingMethodField, |
||||
expError: "iam_http_request_method must be POST", |
||||
}, |
||||
"missing iam_request_url field": { |
||||
tokenStr: tokenJsonMissingUrlField, |
||||
expError: "url is invalid", |
||||
}, |
||||
"missing iam_request_headers field": { |
||||
tokenStr: tokenJsonMissingHeadersField, |
||||
expError: "unexpected end of JSON input", |
||||
}, |
||||
"missing iam_request_body field": { |
||||
tokenStr: tokenJsonMissingBodyField, |
||||
expError: "iam_request_body error", |
||||
}, |
||||
"invalid json": { |
||||
tokenStr: `{`, |
||||
expError: "unexpected end of JSON input", |
||||
}, |
||||
} |
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
token, err := NewBearerToken(c.tokenStr, &c.config) |
||||
t.Logf("token = %+v", token) |
||||
if c.expError != "" { |
||||
require.Error(t, err) |
||||
require.Contains(t, err.Error(), c.expError) |
||||
require.Nil(t, token) |
||||
} else { |
||||
require.NoError(t, err) |
||||
c.expToken.config = &c.config |
||||
require.Equal(t, &c.expToken, token) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestParseRequestBody(t *testing.T) { |
||||
cases := map[string]struct { |
||||
body string |
||||
allowedValues url.Values |
||||
expValues url.Values |
||||
expError string |
||||
}{ |
||||
"one allowed field": { |
||||
body: "Action=GetCallerIdentity&Version=1234", |
||||
allowedValues: url.Values{"Version": []string{"1234"}}, |
||||
expValues: url.Values{ |
||||
"Action": []string{"GetCallerIdentity"}, |
||||
"Version": []string{"1234"}, |
||||
}, |
||||
}, |
||||
"many allowed fields": { |
||||
body: "Action=GetRole&RoleName=my-role&Version=1234", |
||||
allowedValues: url.Values{ |
||||
"Action": []string{"GetUser", "GetRole"}, |
||||
"UserName": nil, |
||||
"RoleName": nil, |
||||
"Version": nil, |
||||
}, |
||||
expValues: url.Values{ |
||||
"Action": []string{"GetRole"}, |
||||
"RoleName": []string{"my-role"}, |
||||
"Version": []string{"1234"}, |
||||
}, |
||||
}, |
||||
"action only": { |
||||
body: "Action=GetRole", |
||||
allowedValues: nil, |
||||
expValues: url.Values{"Action": []string{"GetRole"}}, |
||||
}, |
||||
|
||||
"empty body": { |
||||
expValues: url.Values{}, |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
"disallowed field": { |
||||
body: "Action=GetRole&Version=1234&Extra=Abc", |
||||
allowedValues: url.Values{"Action": nil, "Version": nil}, |
||||
expError: `unexpected field "Extra"`, |
||||
}, |
||||
"mismatched action": { |
||||
body: "Action=GetRole", |
||||
allowedValues: url.Values{"Action": []string{"GetUser"}}, |
||||
expError: `unexpected value Action=[GetRole]`, |
||||
}, |
||||
"mismatched field": { |
||||
body: "Action=GetRole&Extra=1234", |
||||
allowedValues: url.Values{"Action": nil, "Extra": []string{"abc"}}, |
||||
expError: `unexpected value Extra=[1234]`, |
||||
}, |
||||
"multi-valued field": { |
||||
body: "Action=GetRole&Action=GetUser", |
||||
allowedValues: url.Values{"Action": []string{"GetRole", "GetUser"}}, |
||||
// only one value is allowed.
|
||||
expError: `unexpected value Action=[GetRole GetUser]`, |
||||
}, |
||||
"empty action": { |
||||
body: "Action=", |
||||
allowedValues: nil, |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
"missing action": { |
||||
body: "Version=1234", |
||||
allowedValues: url.Values{"Action": []string{"GetRole"}}, |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
} |
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
values, err := parseRequestBody(c.body, c.allowedValues) |
||||
if c.expError != "" { |
||||
require.Error(t, err) |
||||
require.Contains(t, err.Error(), c.expError) |
||||
require.Nil(t, values) |
||||
} else { |
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expValues, values) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestValidateGetCallerIdentityBody(t *testing.T) { |
||||
cases := map[string]struct { |
||||
body string |
||||
expError string |
||||
}{ |
||||
"valid": {"Action=GetCallerIdentity&Version=1234", ""}, |
||||
"valid 2": {"Action=GetCallerIdentity", ""}, |
||||
"empty action": { |
||||
"Action=", |
||||
`iam_request_body error: missing field "Action"`, |
||||
}, |
||||
"invalid action": { |
||||
"Action=GetRole", |
||||
`iam_request_body error: unexpected value Action=[GetRole]`, |
||||
}, |
||||
"missing action": { |
||||
"Version=1234", |
||||
`iam_request_body error: missing field "Action"`, |
||||
}, |
||||
"empty": { |
||||
"", |
||||
`iam_request_body error: missing field "Action"`, |
||||
}, |
||||
} |
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
token := &BearerToken{getCallerIdentityBody: c.body} |
||||
err := token.validateGetCallerIdentityBody() |
||||
if c.expError != "" { |
||||
require.Error(t, err) |
||||
require.Contains(t, err.Error(), c.expError) |
||||
} else { |
||||
require.NoError(t, err) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestValidateIAMEntityBody(t *testing.T) { |
||||
cases := map[string]struct { |
||||
body string |
||||
expReqType string |
||||
expError string |
||||
}{ |
||||
"valid role": { |
||||
body: "Action=GetRole&RoleName=my-role&Version=1234", |
||||
expReqType: "GetRole", |
||||
}, |
||||
"valid role without version": { |
||||
body: "Action=GetRole&RoleName=my-role", |
||||
expReqType: "GetRole", |
||||
}, |
||||
"valid user": { |
||||
body: "Action=GetUser&UserName=my-role&Version=1234", |
||||
expReqType: "GetUser", |
||||
}, |
||||
"valid user without version": { |
||||
body: "Action=GetUser&UserName=my-role", |
||||
expReqType: "GetUser", |
||||
}, |
||||
|
||||
"invalid action": { |
||||
body: "Action=GetCallerIdentity", |
||||
expError: `unexpected value Action=[GetCallerIdentity]`, |
||||
}, |
||||
"role missing action": { |
||||
body: "RoleName=my-role&Version=1234", |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
"user missing action": { |
||||
body: "UserName=my-role&Version=1234", |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
"empty": { |
||||
body: "", |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
"empty action": { |
||||
body: "Action=", |
||||
expError: `missing field "Action"`, |
||||
}, |
||||
"role with user name": { |
||||
body: "Action=GetRole&UserName=my-role&Version=1234", |
||||
expError: `invalid request body`, |
||||
}, |
||||
"user with role name": { |
||||
body: "Action=GetUser&RoleName=my-role&Version=1234", |
||||
expError: `invalid request body`, |
||||
}, |
||||
} |
||||
for name, c := range cases { |
||||
t.Run(name, func(t *testing.T) { |
||||
token := &BearerToken{ |
||||
config: &Config{}, |
||||
getIAMEntityBody: c.body, |
||||
} |
||||
reqType, err := token.validateIAMEntityBody() |
||||
if c.expError != "" { |
||||
require.Error(t, err) |
||||
require.Contains(t, err.Error(), c.expError) |
||||
require.Equal(t, "", reqType) |
||||
} else { |
||||
require.NoError(t, err) |
||||
require.Equal(t, c.expReqType, reqType) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestValidateSTSHostname(t *testing.T) { |
||||
cases := []struct { |
||||
url string |
||||
ok bool |
||||
}{ |
||||
// https://docs.aws.amazon.com/general/latest/gr/sts.html
|
||||
{"sts.us-east-2.amazonaws.com", true}, |
||||
{"sts-fips.us-east-2.amazonaws.com", true}, |
||||
{"sts.us-east-1.amazonaws.com", true}, |
||||
{"sts-fips.us-east-1.amazonaws.com", true}, |
||||
{"sts.us-west-1.amazonaws.com", true}, |
||||
{"sts-fips.us-west-1.amazonaws.com", true}, |
||||
{"sts.us-west-2.amazonaws.com", true}, |
||||
{"sts-fips.us-west-2.amazonaws.com", true}, |
||||
{"sts.af-south-1.amazonaws.com", true}, |
||||
{"sts.ap-east-1.amazonaws.com", true}, |
||||
{"sts.ap-southeast-3.amazonaws.com", true}, |
||||
{"sts.ap-south-1.amazonaws.com", true}, |
||||
{"sts.ap-northeast-3.amazonaws.com", true}, |
||||
{"sts.ap-northeast-2.amazonaws.com", true}, |
||||
{"sts.ap-southeast-1.amazonaws.com", true}, |
||||
{"sts.ap-southeast-2.amazonaws.com", true}, |
||||
{"sts.ap-northeast-1.amazonaws.com", true}, |
||||
{"sts.ca-central-1.amazonaws.com", true}, |
||||
{"sts.eu-central-1.amazonaws.com", true}, |
||||
{"sts.eu-west-1.amazonaws.com", true}, |
||||
{"sts.eu-west-2.amazonaws.com", true}, |
||||
{"sts.eu-south-1.amazonaws.com", true}, |
||||
{"sts.eu-west-3.amazonaws.com", true}, |
||||
{"sts.eu-north-1.amazonaws.com", true}, |
||||
{"sts.me-south-1.amazonaws.com", true}, |
||||
{"sts.sa-east-1.amazonaws.com", true}, |
||||
{"sts.us-gov-east-1.amazonaws.com", true}, |
||||
{"sts.us-gov-west-1.amazonaws.com", true}, |
||||
|
||||
// prefix must be either 'sts.' or 'sts-fips.'
|
||||
{".amazonaws.com", false}, |
||||
{"iam.amazonaws.com", false}, |
||||
{"other.amazonaws.com", false}, |
||||
// suffix must be '.amazonaws.com' and not some other domain
|
||||
{"stsamazonaws.com", false}, |
||||
{"sts-fipsamazonaws.com", false}, |
||||
{"sts.stsamazonaws.com", false}, |
||||
{"sts.notamazonaws.com", false}, |
||||
{"sts-fips.stsamazonaws.com", false}, |
||||
{"sts-fips.notamazonaws.com", false}, |
||||
{"sts.amazonaws.com.spoof", false}, |
||||
{"sts.amazonaws.spoof.com", false}, |
||||
{"xyz.sts.amazonaws.com", false}, |
||||
} |
||||
for _, c := range cases { |
||||
t.Run(c.url, func(t *testing.T) { |
||||
url := "https://" + c.url |
||||
parsedUrl, err := parseUrl(url) |
||||
require.NoError(t, err) |
||||
|
||||
token := &BearerToken{ |
||||
config: &Config{}, |
||||
getCallerIdentityURL: url, |
||||
parsedCallerIdentityURL: parsedUrl, |
||||
} |
||||
err = token.validateSTSHostname() |
||||
if c.ok { |
||||
require.NoError(t, err) |
||||
} else { |
||||
require.Error(t, err) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestValidateIAMHostname(t *testing.T) { |
||||
cases := []struct { |
||||
url string |
||||
ok bool |
||||
}{ |
||||
// https://docs.aws.amazon.com/general/latest/gr/iam-service.html
|
||||
{"iam.amazonaws.com", true}, |
||||
{"iam-fips.amazonaws.com", true}, |
||||
{"iam.us-gov.amazonaws.com", true}, |
||||
{"iam-fips.us-gov.amazonaws.com", true}, |
||||
|
||||
// prefix must be either 'iam.' or 'aim-fips.'
|
||||
{".amazonaws.com", false}, |
||||
{"sts.amazonaws.com", false}, |
||||
{"other.amazonaws.com", false}, |
||||
// suffix must be '.amazonaws.com' and not some other domain
|
||||
{"iamamazonaws.com", false}, |
||||
{"iam-fipsamazonaws.com", false}, |
||||
{"iam.iamamazonaws.com", false}, |
||||
{"iam.notamazonaws.com", false}, |
||||
{"iam-fips.iamamazonaws.com", false}, |
||||
{"iam-fips.notamazonaws.com", false}, |
||||
{"iam.amazonaws.com.spoof", false}, |
||||
{"iam.amazonaws.spoof.com", false}, |
||||
{"xyz.iam.amazonaws.com", false}, |
||||
} |
||||
for _, c := range cases { |
||||
t.Run(c.url, func(t *testing.T) { |
||||
url := "https://" + c.url |
||||
parsedUrl, err := parseUrl(url) |
||||
require.NoError(t, err) |
||||
|
||||
token := &BearerToken{ |
||||
config: &Config{}, |
||||
getCallerIdentityURL: url, |
||||
parsedIAMEntityURL: parsedUrl, |
||||
} |
||||
err = token.validateIAMHostname() |
||||
if c.ok { |
||||
require.NoError(t, err) |
||||
} else { |
||||
require.Error(t, err) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
var ( |
||||
validBearerTokenJson = `{ |
||||
"iam_http_request_method":"POST", |
||||
"iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", |
||||
"iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==", |
||||
"iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" |
||||
}` |
||||
|
||||
validBearerTokenParsed = BearerToken{ |
||||
getCallerIdentityMethod: "POST", |
||||
getCallerIdentityURL: "https://sts.amazonaws.com/", |
||||
getCallerIdentityHeader: http.Header{ |
||||
"Authorization": []string{"AWS4-HMAC-SHA256 Credential=fake/20220322/us-east-1/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-security-token, Signature=efc320b972d07b38b65eb24256805e03149da586d804f8c6364ce98debe080b1"}, |
||||
"Content-Length": []string{"43"}, |
||||
"Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, |
||||
"User-Agent": []string{"aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"}, |
||||
"X-Amz-Date": []string{"20220322T211103Z"}, |
||||
"X-Amz-Security-Token": []string{"fake"}, |
||||
}, |
||||
getCallerIdentityBody: "Action=GetCallerIdentity&Version=2011-06-15", |
||||
parsedCallerIdentityURL: &url.URL{ |
||||
Scheme: "https", |
||||
Host: "sts.amazonaws.com", |
||||
Path: "/", |
||||
}, |
||||
} |
||||
|
||||
validBearerTokenWithRoleJson = `{"iam_http_request_method":"POST","iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==","iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLWtleS1pZC8yMDIyMDMyMi9mYWtlLXJlZ2lvbi9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1jb25zdWwtaWFtLWdldGVudGl0eS1ib2R5O3gtY29uc3VsLWlhbS1nZXRlbnRpdHktaGVhZGVyczt4LWNvbnN1bC1pYW0tZ2V0ZW50aXR5LW1ldGhvZDt4LWNvbnN1bC1pYW0tZ2V0ZW50aXR5LXVybCwgU2lnbmF0dXJlPTU2MWFjMzFiNWFkMDFjMTI0YzU0YzE2OGY3NmVhNmJmZDY0NWI4ZWM1MzQ1ZjgzNTc3MjljOWFhMGI0NzEzMzciXSwiQ29udGVudC1MZW5ndGgiOlsiNDMiXSwiQ29udGVudC1UeXBlIjpbImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZDsgY2hhcnNldD11dGYtOCJdLCJVc2VyLUFnZW50IjpbImF3cy1zZGstZ28vMS40Mi4zNCAoZ28xLjE3LjU7IGRhcndpbjsgYW1kNjQpIl0sIlgtQW16LURhdGUiOlsiMjAyMjAzMjJUMjI1NzQyWiJdLCJYLUNvbnN1bC1JYW0tR2V0ZW50aXR5LUJvZHkiOlsiQWN0aW9uPUdldFJvbGVcdTAwMjZSb2xlTmFtZT1teS1yb2xlXHUwMDI2VmVyc2lvbj0yMDEwLTA1LTA4Il0sIlgtQ29uc3VsLUlhbS1HZXRlbnRpdHktSGVhZGVycyI6WyJ7XCJBdXRob3JpemF0aW9uXCI6W1wiQVdTNC1ITUFDLVNIQTI1NiBDcmVkZW50aWFsPWZha2Uta2V5LWlkLzIwMjIwMzIyL3VzLWVhc3QtMS9pYW0vYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGUsIFNpZ25hdHVyZT1hYTJhMTlkMGEzMDVkNzRiYmQwMDk3NzZiY2E4ODBlNTNjZmE5OTFlNDgzZTQwMzk0NzE4MWE0MWNjNDgyOTQwXCJdLFwiQ29udGVudC1MZW5ndGhcIjpbXCI1MFwiXSxcIkNvbnRlbnQtVHlwZVwiOltcImFwcGxpY2F0aW9uL3gtd3d3LWZvcm0tdXJsZW5jb2RlZDsgY2hhcnNldD11dGYtOFwiXSxcIlVzZXItQWdlbnRcIjpbXCJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KVwiXSxcIlgtQW16LURhdGVcIjpbXCIyMDIyMDMyMlQyMjU3NDJaXCJdfSJdLCJYLUNvbnN1bC1JYW0tR2V0ZW50aXR5LU1ldGhvZCI6WyJQT1NUIl0sIlgtQ29uc3VsLUlhbS1HZXRlbnRpdHktVXJsIjpbImh0dHBzOi8vaWFtLmFtYXpvbmF3cy5jb20vIl19","iam_request_url":"aHR0cDovLzEyNy4wLjAuMTo2MzY5Ni9zdHMv"}` |
||||
|
||||
validBearerTokenWithRoleParsed = BearerToken{ |
||||
getCallerIdentityMethod: "POST", |
||||
getCallerIdentityURL: "http://127.0.0.1:63696/sts/", |
||||
getCallerIdentityHeader: http.Header{ |
||||
"Authorization": []string{"AWS4-HMAC-SHA256 Credential=fake-key-id/20220322/fake-region/sts/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-consul-iam-getentity-body;x-consul-iam-getentity-headers;x-consul-iam-getentity-method;x-consul-iam-getentity-url, Signature=561ac31b5ad01c124c54c168f76ea6bfd645b8ec5345f8357729c9aa0b471337"}, |
||||
"Content-Length": []string{"43"}, |
||||
"Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, |
||||
"User-Agent": []string{"aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"}, |
||||
"X-Amz-Date": []string{"20220322T225742Z"}, |
||||
"X-Consul-Iam-Getentity-Body": []string{"Action=GetRole&RoleName=my-role&Version=2010-05-08"}, |
||||
"X-Consul-Iam-Getentity-Headers": []string{`{"Authorization":["AWS4-HMAC-SHA256 Credential=fake-key-id/20220322/us-east-1/iam/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date, Signature=aa2a19d0a305d74bbd009776bca880e53cfa991e483e403947181a41cc482940"],"Content-Length":["50"],"Content-Type":["application/x-www-form-urlencoded; charset=utf-8"],"User-Agent":["aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"],"X-Amz-Date":["20220322T225742Z"]}`}, |
||||
"X-Consul-Iam-Getentity-Method": []string{"POST"}, |
||||
"X-Consul-Iam-Getentity-Url": []string{"https://iam.amazonaws.com/"}, |
||||
}, |
||||
getCallerIdentityBody: "Action=GetCallerIdentity&Version=2011-06-15", |
||||
|
||||
// Fields parsed from headers above
|
||||
getIAMEntityMethod: "POST", |
||||
getIAMEntityURL: "https://iam.amazonaws.com/", |
||||
getIAMEntityHeader: http.Header{ |
||||
"Authorization": []string{"AWS4-HMAC-SHA256 Credential=fake-key-id/20220322/us-east-1/iam/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date, Signature=aa2a19d0a305d74bbd009776bca880e53cfa991e483e403947181a41cc482940"}, |
||||
"Content-Length": []string{"50"}, |
||||
"Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"}, |
||||
"User-Agent": []string{"aws-sdk-go/1.42.34 (go1.17.5; darwin; amd64)"}, |
||||
"X-Amz-Date": []string{"20220322T225742Z"}, |
||||
}, |
||||
getIAMEntityBody: "Action=GetRole&RoleName=my-role&Version=2010-05-08", |
||||
entityRequestType: "GetRole", |
||||
|
||||
parsedCallerIdentityURL: &url.URL{ |
||||
Scheme: "http", |
||||
Host: "127.0.0.1:63696", |
||||
Path: "/sts/", |
||||
}, |
||||
parsedIAMEntityURL: &url.URL{ |
||||
Scheme: "https", |
||||
Host: "iam.amazonaws.com", |
||||
Path: "/", |
||||
}, |
||||
} |
||||
|
||||
tokenJsonMissingMethodField = `{ |
||||
"iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", |
||||
"iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==", |
||||
"iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" |
||||
}` |
||||
|
||||
tokenJsonMissingBodyField = `{ |
||||
"iam_http_request_method":"POST", |
||||
"iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==", |
||||
"iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" |
||||
}` |
||||
|
||||
tokenJsonMissingHeadersField = `{ |
||||
"iam_http_request_method":"POST", |
||||
"iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", |
||||
"iam_request_url":"aHR0cHM6Ly9zdHMuYW1hem9uYXdzLmNvbS8=" |
||||
}` |
||||
|
||||
tokenJsonMissingUrlField = `{ |
||||
"iam_http_request_method":"POST", |
||||
"iam_request_body":"QWN0aW9uPUdldENhbGxlcklkZW50aXR5JlZlcnNpb249MjAxMS0wNi0xNQ==", |
||||
"iam_request_headers":"eyJBdXRob3JpemF0aW9uIjpbIkFXUzQtSE1BQy1TSEEyNTYgQ3JlZGVudGlhbD1mYWtlLzIwMjIwMzIyL3VzLWVhc3QtMS9zdHMvYXdzNF9yZXF1ZXN0LCBTaWduZWRIZWFkZXJzPWNvbnRlbnQtbGVuZ3RoO2NvbnRlbnQtdHlwZTtob3N0O3gtYW16LWRhdGU7eC1hbXotc2VjdXJpdHktdG9rZW4sIFNpZ25hdHVyZT1lZmMzMjBiOTcyZDA3YjM4YjY1ZWIyNDI1NjgwNWUwMzE0OWRhNTg2ZDgwNGY4YzYzNjRjZTk4ZGViZTA4MGIxIl0sIkNvbnRlbnQtTGVuZ3RoIjpbIjQzIl0sIkNvbnRlbnQtVHlwZSI6WyJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQ7IGNoYXJzZXQ9dXRmLTgiXSwiVXNlci1BZ2VudCI6WyJhd3Mtc2RrLWdvLzEuNDIuMzQgKGdvMS4xNy41OyBkYXJ3aW47IGFtZDY0KSJdLCJYLUFtei1EYXRlIjpbIjIwMjIwMzIyVDIxMTEwM1oiXSwiWC1BbXotU2VjdXJpdHktVG9rZW4iOlsiZmFrZSJdfQ==" |
||||
}` |
||||
) |
@ -1,143 +0,0 @@
|
||||
package iamauth |
||||
|
||||
import ( |
||||
"encoding/base64" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io/ioutil" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/aws/credentials" |
||||
"github.com/aws/aws-sdk-go/aws/endpoints" |
||||
"github.com/aws/aws-sdk-go/aws/request" |
||||
"github.com/aws/aws-sdk-go/aws/session" |
||||
"github.com/aws/aws-sdk-go/service/iam" |
||||
"github.com/aws/aws-sdk-go/service/sts" |
||||
"github.com/hashicorp/consul/internal/iamauth/responses" |
||||
"github.com/hashicorp/go-hclog" |
||||
) |
||||
|
||||
type LoginInput struct { |
||||
Creds *credentials.Credentials |
||||
IncludeIAMEntity bool |
||||
STSEndpoint string |
||||
STSRegion string |
||||
|
||||
Logger hclog.Logger |
||||
|
||||
ServerIDHeaderValue string |
||||
// Customizable header names
|
||||
ServerIDHeaderName string |
||||
GetEntityMethodHeader string |
||||
GetEntityURLHeader string |
||||
GetEntityHeadersHeader string |
||||
GetEntityBodyHeader string |
||||
} |
||||
|
||||
// GenerateLoginData populates the necessary data to send for the bearer token.
|
||||
// https://github.com/hashicorp/go-secure-stdlib/blob/main/awsutil/generate_credentials.go#L232-L301
|
||||
func GenerateLoginData(in *LoginInput) (map[string]interface{}, error) { |
||||
cfg := aws.Config{ |
||||
Credentials: in.Creds, |
||||
// These are empty strings by default (i.e. not enabled)
|
||||
Region: aws.String(in.STSRegion), |
||||
Endpoint: aws.String(in.STSEndpoint), |
||||
STSRegionalEndpoint: endpoints.RegionalSTSEndpoint, |
||||
} |
||||
|
||||
stsSession, err := session.NewSessionWithOptions(session.Options{Config: cfg}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
svc := sts.New(stsSession) |
||||
stsRequest, _ := svc.GetCallerIdentityRequest(nil) |
||||
|
||||
// Include the iam:GetRole or iam:GetUser request in headers.
|
||||
if in.IncludeIAMEntity { |
||||
entityRequest, err := formatSignedEntityRequest(svc, in) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
headersJson, err := json.Marshal(entityRequest.HTTPRequest.Header) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
requestBody, err := ioutil.ReadAll(entityRequest.HTTPRequest.Body) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
stsRequest.HTTPRequest.Header.Add(in.GetEntityMethodHeader, entityRequest.HTTPRequest.Method) |
||||
stsRequest.HTTPRequest.Header.Add(in.GetEntityURLHeader, entityRequest.HTTPRequest.URL.String()) |
||||
stsRequest.HTTPRequest.Header.Add(in.GetEntityHeadersHeader, string(headersJson)) |
||||
stsRequest.HTTPRequest.Header.Add(in.GetEntityBodyHeader, string(requestBody)) |
||||
} |
||||
|
||||
// Inject the required auth header value, if supplied, and then sign the request including that header
|
||||
if in.ServerIDHeaderValue != "" { |
||||
stsRequest.HTTPRequest.Header.Add(in.ServerIDHeaderName, in.ServerIDHeaderValue) |
||||
} |
||||
|
||||
stsRequest.Sign() |
||||
|
||||
// Now extract out the relevant parts of the request
|
||||
headersJson, err := json.Marshal(stsRequest.HTTPRequest.Header) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
requestBody, err := ioutil.ReadAll(stsRequest.HTTPRequest.Body) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return map[string]interface{}{ |
||||
"iam_http_request_method": stsRequest.HTTPRequest.Method, |
||||
"iam_request_url": base64.StdEncoding.EncodeToString([]byte(stsRequest.HTTPRequest.URL.String())), |
||||
"iam_request_headers": base64.StdEncoding.EncodeToString(headersJson), |
||||
"iam_request_body": base64.StdEncoding.EncodeToString(requestBody), |
||||
}, nil |
||||
} |
||||
|
||||
func formatSignedEntityRequest(svc *sts.STS, in *LoginInput) (*request.Request, error) { |
||||
// We need to retrieve the IAM user or role for the iam:GetRole or iam:GetUser request.
|
||||
// GetCallerIdentity returns this and requires no permissions.
|
||||
resp, err := svc.GetCallerIdentity(nil) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
arn, err := responses.ParseArn(*resp.Arn) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
iamSession, err := session.NewSessionWithOptions(session.Options{ |
||||
Config: aws.Config{ |
||||
Credentials: svc.Config.Credentials, |
||||
}, |
||||
}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
iamSvc := iam.New(iamSession) |
||||
|
||||
var req *request.Request |
||||
switch arn.Type { |
||||
case "role", "assumed-role": |
||||
req, _ = iamSvc.GetRoleRequest(&iam.GetRoleInput{RoleName: &arn.FriendlyName}) |
||||
case "user": |
||||
req, _ = iamSvc.GetUserRequest(&iam.GetUserInput{UserName: &arn.FriendlyName}) |
||||
default: |
||||
return nil, fmt.Errorf("entity %s is not an IAM role or IAM user", arn.Type) |
||||
} |
||||
|
||||
// Inject the required auth header value, if supplied, and then sign the request including that header
|
||||
if in.ServerIDHeaderValue != "" { |
||||
req.HTTPRequest.Header.Add(in.ServerIDHeaderName, in.ServerIDHeaderValue) |
||||
} |
||||
|
||||
req.Sign() |
||||
return req, nil |
||||
} |
@ -1,24 +0,0 @@
|
||||
package lib |
||||
|
||||
import "strings" |
||||
|
||||
// GlobbedStringsMatch compares item to val with support for a leading and/or
|
||||
// trailing wildcard '*' in item.
|
||||
func GlobbedStringsMatch(item, val string) bool { |
||||
if len(item) < 2 { |
||||
return val == item |
||||
} |
||||
|
||||
hasPrefix := strings.HasPrefix(item, "*") |
||||
hasSuffix := strings.HasSuffix(item, "*") |
||||
|
||||
if hasPrefix && hasSuffix { |
||||
return strings.Contains(val, item[1:len(item)-1]) |
||||
} else if hasPrefix { |
||||
return strings.HasSuffix(val, item[1:]) |
||||
} else if hasSuffix { |
||||
return strings.HasPrefix(val, item[:len(item)-1]) |
||||
} |
||||
|
||||
return val == item |
||||
} |
@ -1,37 +0,0 @@
|
||||
package lib |
||||
|
||||
import "testing" |
||||
|
||||
func TestGlobbedStringsMatch(t *testing.T) { |
||||
tests := []struct { |
||||
item string |
||||
val string |
||||
expect bool |
||||
}{ |
||||
{"", "", true}, |
||||
{"*", "*", true}, |
||||
{"**", "**", true}, |
||||
{"*t", "t", true}, |
||||
{"*t", "test", true}, |
||||
{"t*", "test", true}, |
||||
{"*test", "test", true}, |
||||
{"*test", "a test", true}, |
||||
{"test", "a test", false}, |
||||
{"*test", "tests", false}, |
||||
{"test*", "test", true}, |
||||
{"test*", "testsss", true}, |
||||
{"test**", "testsss", false}, |
||||
{"test**", "test*", true}, |
||||
{"**test", "*test", true}, |
||||
{"TEST", "test", false}, |
||||
{"test", "test", true}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
actual := GlobbedStringsMatch(tt.item, tt.val) |
||||
|
||||
if actual != tt.expect { |
||||
t.Fatalf("Bad testcase %#v, expected %t, got %t", tt, tt.expect, actual) |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue