Extract AWS auth implementation out of Consul (#13760)

This commit is contained in:
Paul Glass 2022-07-19 15:26:44 -06:00 committed by GitHub
parent dcc230f699
commit a9f17c0f99
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 9 additions and 2514 deletions

View File

@ -4,9 +4,9 @@ import (
"context"
"fmt"
iamauth "github.com/hashicorp/consul-awsauth"
"github.com/hashicorp/consul/agent/consul/authmethod"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/internal/iamauth"
"github.com/hashicorp/go-hclog"
)

View File

@ -8,10 +8,10 @@ import (
"testing"
"github.com/aws/aws-sdk-go/aws/credentials"
iamauth "github.com/hashicorp/consul-awsauth"
"github.com/hashicorp/consul-awsauth/iamauthtest"
"github.com/hashicorp/consul/agent/consul/authmethod"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/internal/iamauth"
"github.com/hashicorp/consul/internal/iamauth/iamauthtest"
"github.com/hashicorp/go-hclog"
"github.com/stretchr/testify/require"
)

View File

@ -9,8 +9,8 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
iamauth "github.com/hashicorp/consul-awsauth"
"github.com/hashicorp/consul/agent/consul/authmethod/awsauth"
"github.com/hashicorp/consul/internal/iamauth"
"github.com/hashicorp/go-hclog"
)

View File

@ -13,13 +13,13 @@ import (
"github.com/stretchr/testify/require"
"gopkg.in/square/go-jose.v2/jwt"
"github.com/hashicorp/consul-awsauth/iamauthtest"
"github.com/hashicorp/consul/agent"
"github.com/hashicorp/consul/agent/consul/authmethod/kubeauth"
"github.com/hashicorp/consul/agent/consul/authmethod/testauth"
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/command/acl"
"github.com/hashicorp/consul/internal/go-sso/oidcauth/oidcauthtest"
"github.com/hashicorp/consul/internal/iamauth/iamauthtest"
"github.com/hashicorp/consul/sdk/testutil"
"github.com/hashicorp/consul/testrpc"
)

3
go.mod
View File

@ -25,6 +25,7 @@ require (
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22
github.com/google/tcpproxy v0.0.0-20180808230851-dfa16c61dad2
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0
github.com/hashicorp/consul-awsauth v0.0.0-20220713182709-05ac1c5c2706
github.com/hashicorp/consul-net-rpc v0.0.0-20220307172752-3602954411b4
github.com/hashicorp/consul/api v1.13.1
github.com/hashicorp/consul/sdk v0.10.0
@ -37,7 +38,6 @@ require (
github.com/hashicorp/go-memdb v1.3.2
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-raftchunking v0.6.2
github.com/hashicorp/go-retryablehttp v0.6.7
github.com/hashicorp/go-sockaddr v1.0.2
github.com/hashicorp/go-syslog v1.0.0
github.com/hashicorp/go-uuid v1.0.2
@ -133,6 +133,7 @@ require (
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-immutable-radix v1.3.0 // indirect
github.com/hashicorp/go-msgpack v0.5.5 // indirect
github.com/hashicorp/go-retryablehttp v0.6.7 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/mdns v1.0.4 // indirect
github.com/hashicorp/raft-boltdb v0.0.0-20211202195631-7d34b9fb3f42 // indirect

2
go.sum
View File

@ -294,6 +294,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmg
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul-awsauth v0.0.0-20220713182709-05ac1c5c2706 h1:1ZEjnveDe20yFa6lSkfdQZm5BR/b271n0MsB5R2L3us=
github.com/hashicorp/consul-awsauth v0.0.0-20220713182709-05ac1c5c2706/go.mod h1:1Cs8FlmD1BfSQXJGcFLSV5FuIx1AbJP+EJGdxosoS2g=
github.com/hashicorp/consul-net-rpc v0.0.0-20220307172752-3602954411b4 h1:Com/5n/omNSBusX11zdyIYtidiqewLIanchbm//McZA=
github.com/hashicorp/consul-net-rpc v0.0.0-20220307172752-3602954411b4/go.mod h1:vWEAHAeAqfOwB3pSgHMQpIu8VH1jL+Ltg54Tw0wt/NI=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=

View File

@ -1,2 +0,0 @@
This is an internal package to house the AWS IAM auth method utilities for potential
future extraction from Consul.

View File

@ -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")
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"},
},
}
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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=="
}`
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}