mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 01:58:44 +08:00
Cap AWS AssumeRole sessions to Teleport identity TTL (#67617)
This commit is contained in:
+114
-34
@@ -19,6 +19,7 @@
|
||||
package aws
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -36,69 +37,148 @@ import (
|
||||
)
|
||||
|
||||
func updateAssumeRoleDuration(identity *tlsca.Identity, w http.ResponseWriter, req *http.Request, clock clockwork.Clock) error {
|
||||
// Skip non-AssumeRole request
|
||||
query, found, err := getAssumeRoleQuery(req)
|
||||
if err != nil || !found {
|
||||
assumeRoleReq, identityTTL, err := checkAssumeRoleDuration(identity, req, clock)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Deny access if identity duration is shorter than the minimum that can be
|
||||
// requested.
|
||||
identityTTL := identity.Expires.Sub(clock.Now())
|
||||
if identityTTL < assumeRoleMinDuration {
|
||||
// TODO write error message in XML so the client can understand.
|
||||
return trace.AccessDenied("minimum AWS session duration is %v but Teleport identity expires in %v. Please re-login the app and try again.", assumeRoleMinDuration, identityTTL)
|
||||
}
|
||||
|
||||
// Use shorter requested duration (no update required).
|
||||
if getAssumeRoleQueryDuration(query) <= identityTTL {
|
||||
// Skip non-AssumeRole requests and those already within the TTL.
|
||||
if assumeRoleReq == nil || assumeRoleReq.getDuration() <= identityTTL {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rewrite the request.
|
||||
if err := rewriteAssumeRoleQuery(req, withAssumeRoleQueryDuration(query, identityTTL)); err != nil {
|
||||
if err := rewriteAssumeRoleRequest(req, assumeRoleReq, identityTTL); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
w.Header().Add(common.TeleportAPIInfoHeader, fmt.Sprintf("requested DurationSeconds of AssumeRole is lowered to \"%d\" as the Teleport identity will expire at %v", int(identityTTL.Seconds()), identity.Expires))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAssumeRoleQuery extracts AssumeRole query values from provided request.
|
||||
func denyLongAssumeRoleDuration(identity *tlsca.Identity, req *http.Request, clock clockwork.Clock) error {
|
||||
assumeRoleReq, identityTTL, err := checkAssumeRoleDuration(identity, req, clock)
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
if assumeRoleReq == nil || assumeRoleReq.getDuration() <= identityTTL {
|
||||
return nil
|
||||
}
|
||||
|
||||
return trace.AccessDenied("requested DurationSeconds of AssumeRole is longer than the Teleport identity TTL of %v", identityTTL)
|
||||
}
|
||||
|
||||
func checkAssumeRoleDuration(identity *tlsca.Identity, req *http.Request, clock clockwork.Clock) (*assumeRoleRequestParams, time.Duration, error) {
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
if err != nil {
|
||||
return nil, 0, trace.Wrap(err)
|
||||
}
|
||||
if assumeRoleReq == nil {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
identityTTL := identity.Expires.Sub(clock.Now())
|
||||
if identityTTL < assumeRoleMinDuration {
|
||||
// TODO write error message in XML so the client can understand.
|
||||
return nil, 0, trace.AccessDenied("minimum AWS session duration is %v but Teleport identity expires in %v. Please re-login the app and try again.", assumeRoleMinDuration, identityTTL)
|
||||
}
|
||||
return assumeRoleReq, identityTTL, nil
|
||||
}
|
||||
|
||||
type assumeRoleRequestParams struct {
|
||||
query url.Values
|
||||
postForm url.Values
|
||||
|
||||
actionInQuery bool
|
||||
actionInPostForm bool
|
||||
|
||||
durationInQuery bool
|
||||
durationInPostForm bool
|
||||
}
|
||||
|
||||
// getAssumeRoleRequest extracts AssumeRole query and post form values from
|
||||
// provided request.
|
||||
//
|
||||
// AWS SDK reference:
|
||||
// https://github.com/aws/aws-sdk-go/blob/main/private/protocol/query/build.go
|
||||
// https://github.com/aws/aws-sdk-go/blob/main/service/sts/api.go
|
||||
func getAssumeRoleQuery(req *http.Request) (url.Values, bool, error) {
|
||||
func getAssumeRoleRequest(req *http.Request) (*assumeRoleRequestParams, error) {
|
||||
// http.Request.ParseForm may drain the body. Use a clone.
|
||||
clone, err := cloneRequest(req)
|
||||
if err != nil {
|
||||
return nil, false, trace.Wrap(err)
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
if err := clone.ParseForm(); err != nil || clone.PostForm == nil {
|
||||
return nil, false, nil
|
||||
if err := clone.ParseForm(); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if clone.PostForm.Get("Action") != "AssumeRole" {
|
||||
return nil, false, nil
|
||||
|
||||
query := clone.URL.Query()
|
||||
postForm := clone.PostForm
|
||||
if postForm == nil {
|
||||
postForm = url.Values{}
|
||||
}
|
||||
return clone.PostForm, true, nil
|
||||
|
||||
assumeRoleReq := &assumeRoleRequestParams{
|
||||
query: query,
|
||||
postForm: postForm,
|
||||
actionInQuery: query.Get(assumeRoleQueryKeyAction) == assumeRoleQueryActionAssumeRole,
|
||||
actionInPostForm: postForm.Get(assumeRoleQueryKeyAction) == assumeRoleQueryActionAssumeRole,
|
||||
durationInQuery: query.Has(assumeRoleQueryKeyDurationSeconds),
|
||||
durationInPostForm: postForm.Has(assumeRoleQueryKeyDurationSeconds),
|
||||
}
|
||||
if !assumeRoleReq.actionInQuery && !assumeRoleReq.actionInPostForm {
|
||||
return nil, nil
|
||||
}
|
||||
return assumeRoleReq, nil
|
||||
}
|
||||
|
||||
func getAssumeRoleQueryDuration(query url.Values) time.Duration {
|
||||
if durationSeconds, err := strconv.ParseInt(query.Get(assumeRoleQueryKeyDurationSeconds), 10, 32); err == nil {
|
||||
return time.Duration(durationSeconds) * time.Second
|
||||
func parseAssumeRoleDuration(values url.Values) (time.Duration, bool) {
|
||||
if durationSeconds, err := strconv.ParseInt(values.Get(assumeRoleQueryKeyDurationSeconds), 10, 32); err == nil {
|
||||
return time.Duration(durationSeconds) * time.Second, true
|
||||
}
|
||||
return assumeRoleDefaultDuration
|
||||
}
|
||||
func withAssumeRoleQueryDuration(query url.Values, duration time.Duration) url.Values {
|
||||
query.Set(assumeRoleQueryKeyDurationSeconds, strconv.Itoa(int(duration.Seconds())))
|
||||
return query
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func rewriteAssumeRoleQuery(req *http.Request, query url.Values) error {
|
||||
return trace.Wrap(utils.ReplaceRequestBody(req, io.NopCloser(strings.NewReader(query.Encode()))))
|
||||
func (r *assumeRoleRequestParams) getDuration() time.Duration {
|
||||
var duration time.Duration
|
||||
for _, values := range []url.Values{r.query, r.postForm} {
|
||||
if candidate, ok := parseAssumeRoleDuration(values); ok && candidate > duration {
|
||||
duration = candidate
|
||||
}
|
||||
}
|
||||
return cmp.Or(duration, assumeRoleDefaultDuration)
|
||||
}
|
||||
|
||||
func rewriteAssumeRoleRequest(req *http.Request, assumeRoleReq *assumeRoleRequestParams, duration time.Duration) error {
|
||||
durationSeconds := strconv.Itoa(int(duration.Seconds()))
|
||||
if assumeRoleReq.actionInQuery || assumeRoleReq.durationInQuery {
|
||||
query := req.URL.Query()
|
||||
query.Set(assumeRoleQueryKeyDurationSeconds, durationSeconds)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
if req.RequestURI != "" {
|
||||
req.RequestURI = req.URL.RequestURI()
|
||||
}
|
||||
}
|
||||
|
||||
if !assumeRoleReq.actionInPostForm && !assumeRoleReq.durationInPostForm {
|
||||
return nil
|
||||
}
|
||||
|
||||
assumeRoleReq.postForm.Set(assumeRoleQueryKeyDurationSeconds, durationSeconds)
|
||||
body := assumeRoleReq.postForm.Encode()
|
||||
if err := utils.ReplaceRequestBody(req, io.NopCloser(strings.NewReader(body))); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
// assumeRoleQueryKeyAction is the query key for the STS API action.
|
||||
assumeRoleQueryKeyAction = "Action"
|
||||
|
||||
// assumeRoleQueryActionAssumeRole is the STS AssumeRole API action name.
|
||||
assumeRoleQueryActionAssumeRole = "AssumeRole"
|
||||
|
||||
// assumeRoleQueryKeyDurationSeconds is the query key for the duration
|
||||
// seconds for the AssumeRole request.
|
||||
assumeRoleQueryKeyDurationSeconds = "DurationSeconds"
|
||||
@@ -108,8 +188,8 @@ const (
|
||||
//
|
||||
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
|
||||
assumeRoleMinDuration = 15 * time.Minute
|
||||
// assumeRoleMinDuration is the default duration if DurationSeconds is not
|
||||
// explicitly set in the AssumeRole request.
|
||||
// assumeRoleDefaultDuration is the default duration if DurationSeconds is
|
||||
// not explicitly set in the AssumeRole request.
|
||||
//
|
||||
// https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
|
||||
assumeRoleDefaultDuration = 1 * time.Hour
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2026 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package aws
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gravitational/trace"
|
||||
"github.com/jonboulle/clockwork"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/gravitational/teleport/lib/srv/app/common"
|
||||
"github.com/gravitational/teleport/lib/tlsca"
|
||||
)
|
||||
|
||||
func TestGetAssumeRoleRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request *http.Request
|
||||
found bool
|
||||
wantDuration time.Duration
|
||||
actionInQuery bool
|
||||
actionInBody bool
|
||||
}{
|
||||
{
|
||||
name: "post form",
|
||||
request: newAssumeRolePostRequest(t, "3600"),
|
||||
found: true,
|
||||
wantDuration: time.Hour,
|
||||
actionInBody: true,
|
||||
},
|
||||
{
|
||||
name: "query",
|
||||
request: newAssumeRoleQueryRequest(t, "1800"),
|
||||
found: true,
|
||||
wantDuration: 30 * time.Minute,
|
||||
actionInQuery: true,
|
||||
},
|
||||
{
|
||||
name: "default duration",
|
||||
request: newAssumeRolePostRequest(t, ""),
|
||||
found: true,
|
||||
wantDuration: assumeRoleDefaultDuration,
|
||||
actionInBody: true,
|
||||
},
|
||||
{
|
||||
name: "uses longest duration from query and body",
|
||||
request: func() *http.Request {
|
||||
req := newAssumeRolePostRequest(t, "1800")
|
||||
query := req.URL.Query()
|
||||
query.Set(assumeRoleQueryKeyAction, assumeRoleQueryActionAssumeRole)
|
||||
query.Set(assumeRoleQueryKeyDurationSeconds, "7200")
|
||||
req.URL.RawQuery = query.Encode()
|
||||
return req
|
||||
}(),
|
||||
found: true,
|
||||
wantDuration: 2 * time.Hour,
|
||||
actionInQuery: true,
|
||||
actionInBody: true,
|
||||
},
|
||||
{
|
||||
name: "not assume role",
|
||||
request: httptest.NewRequest(http.MethodPost, "https://sts.amazonaws.com/", strings.NewReader(url.Values{"Action": {"GetCallerIdentity"}}.Encode())),
|
||||
found: false,
|
||||
},
|
||||
{
|
||||
name: "malformed post form",
|
||||
request: func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "https://sts.amazonaws.com/", strings.NewReader("Action=AssumeRole&DurationSeconds=%"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}(),
|
||||
found: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assumeRoleReq, err := getAssumeRoleRequest(tt.request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.found, assumeRoleReq != nil)
|
||||
if assumeRoleReq == nil {
|
||||
return
|
||||
}
|
||||
require.Equal(t, tt.wantDuration, assumeRoleReq.getDuration())
|
||||
require.Equal(t, tt.actionInQuery, assumeRoleReq.actionInQuery)
|
||||
require.Equal(t, tt.actionInBody, assumeRoleReq.actionInPostForm)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteAssumeRoleRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("post form", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := newAssumeRolePostRequest(t, "43200")
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assumeRoleReq)
|
||||
|
||||
require.NoError(t, rewriteAssumeRoleRequest(req, assumeRoleReq, 30*time.Minute))
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
values, err := url.ParseQuery(string(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1800", values.Get(assumeRoleQueryKeyDurationSeconds))
|
||||
require.Equal(t, int64(len(string(body))), req.ContentLength)
|
||||
})
|
||||
|
||||
t.Run("query", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := newAssumeRoleQueryRequest(t, "43200")
|
||||
req.RequestURI = req.URL.RequestURI()
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assumeRoleReq)
|
||||
|
||||
require.NoError(t, rewriteAssumeRoleRequest(req, assumeRoleReq, 30*time.Minute))
|
||||
require.Equal(t, "1800", req.URL.Query().Get(assumeRoleQueryKeyDurationSeconds))
|
||||
require.Equal(t, req.URL.RequestURI(), req.RequestURI)
|
||||
})
|
||||
|
||||
t.Run("query and post form", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := newAssumeRolePostRequest(t, "1800")
|
||||
query := req.URL.Query()
|
||||
query.Set(assumeRoleQueryKeyAction, assumeRoleQueryActionAssumeRole)
|
||||
query.Set(assumeRoleQueryKeyDurationSeconds, "7200")
|
||||
req.URL.RawQuery = query.Encode()
|
||||
req.RequestURI = req.URL.RequestURI()
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assumeRoleReq)
|
||||
|
||||
require.NoError(t, rewriteAssumeRoleRequest(req, assumeRoleReq, 45*time.Minute))
|
||||
|
||||
require.Equal(t, "2700", req.URL.Query().Get(assumeRoleQueryKeyDurationSeconds))
|
||||
require.Equal(t, req.URL.RequestURI(), req.RequestURI)
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
require.NoError(t, err)
|
||||
values, err := url.ParseQuery(string(body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "2700", values.Get(assumeRoleQueryKeyDurationSeconds))
|
||||
require.Equal(t, int64(len(string(body))), req.ContentLength)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAssumeRoleDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("explicit duration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clock := clockwork.NewFakeClockAt(time.Now())
|
||||
identity := &tlsca.Identity{Expires: clock.Now().Add(time.Hour)}
|
||||
req := newAssumeRolePostRequest(t, "43200")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
require.NoError(t, updateAssumeRoleDuration(identity, w, req, clock))
|
||||
require.Contains(t, w.Header().Get(common.TeleportAPIInfoHeader), "requested DurationSeconds of AssumeRole is lowered")
|
||||
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assumeRoleReq)
|
||||
require.Equal(t, time.Hour, assumeRoleReq.getDuration())
|
||||
})
|
||||
|
||||
t.Run("default duration", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clock := clockwork.NewFakeClockAt(time.Now())
|
||||
identity := &tlsca.Identity{Expires: clock.Now().Add(30 * time.Minute)}
|
||||
req := newAssumeRolePostRequest(t, "")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
require.NoError(t, updateAssumeRoleDuration(identity, w, req, clock))
|
||||
require.Contains(t, w.Header().Get(common.TeleportAPIInfoHeader), "requested DurationSeconds of AssumeRole is lowered")
|
||||
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assumeRoleReq)
|
||||
require.Equal(t, 30*time.Minute, assumeRoleReq.getDuration())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDenyLongAssumeRoleDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clock := clockwork.NewFakeClockAt(time.Now())
|
||||
identity := &tlsca.Identity{Expires: clock.Now().Add(time.Hour)}
|
||||
|
||||
require.NoError(t, denyLongAssumeRoleDuration(identity, newAssumeRolePostRequest(t, "1800"), clock))
|
||||
|
||||
err := denyLongAssumeRoleDuration(identity, newAssumeRolePostRequest(t, "7200"), clock)
|
||||
require.True(t, trace.IsAccessDenied(err), "got %v", err)
|
||||
|
||||
req := newAssumeRolePostRequest(t, "1800")
|
||||
query := req.URL.Query()
|
||||
query.Set(assumeRoleQueryKeyDurationSeconds, "7200")
|
||||
req.URL.RawQuery = query.Encode()
|
||||
err = denyLongAssumeRoleDuration(identity, req, clock)
|
||||
require.True(t, trace.IsAccessDenied(err), "got %v", err)
|
||||
|
||||
err = denyLongAssumeRoleDuration(&tlsca.Identity{Expires: clock.Now().Add(10 * time.Minute)}, newAssumeRolePostRequest(t, "900"), clock)
|
||||
require.True(t, trace.IsAccessDenied(err), "got %v", err)
|
||||
}
|
||||
|
||||
func newAssumeRolePostRequest(t *testing.T, durationSeconds string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
form := url.Values{
|
||||
assumeRoleQueryKeyAction: {assumeRoleQueryActionAssumeRole},
|
||||
"RoleArn": {"arn:aws:iam::123456789012:role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
}
|
||||
if durationSeconds != "" {
|
||||
form.Set(assumeRoleQueryKeyDurationSeconds, durationSeconds)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "https://sts.amazonaws.com/", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return req
|
||||
}
|
||||
|
||||
func newAssumeRoleQueryRequest(t *testing.T, durationSeconds string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
query := url.Values{
|
||||
assumeRoleQueryKeyAction: {assumeRoleQueryActionAssumeRole},
|
||||
"RoleArn": {"arn:aws:iam::123456789012:role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
}
|
||||
if durationSeconds != "" {
|
||||
query.Set(assumeRoleQueryKeyDurationSeconds, durationSeconds)
|
||||
}
|
||||
return httptest.NewRequest(http.MethodGet, "https://sts.amazonaws.com/?"+query.Encode(), http.NoBody)
|
||||
}
|
||||
@@ -210,6 +210,11 @@ func (s *signerHandler) serveRequestByAssumedRole(sessCtx *common.SessionContext
|
||||
if err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
if strings.EqualFold(re.SigningName, sts.ServiceID) {
|
||||
if err := denyLongAssumeRoleDuration(sessCtx.Identity, req, s.Clock); err != nil {
|
||||
return trace.Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
reqCloneForAudit, err := cloneRequest(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -171,6 +172,164 @@ func assumeRoleRequest(requestDuration time.Duration) makeRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func assumeRoleRequestByAssumedRole(requestDuration time.Duration) makeRequest {
|
||||
return func(ctx context.Context, url string, region string, provider aws.CredentialsProvider, awsHost string) error {
|
||||
stsClient := stsutils.NewFromConfig(aws.Config{
|
||||
Credentials: provider,
|
||||
BaseEndpoint: &url,
|
||||
Region: region,
|
||||
RetryMaxAttempts: 0,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &requestByAssumedRoleTransport{xForwardedHost: awsHost},
|
||||
},
|
||||
})
|
||||
_, err := stsClient.AssumeRole(ctx, &sts.AssumeRoleInput{
|
||||
DurationSeconds: aws.Int32(int32(requestDuration.Seconds())),
|
||||
RoleSessionName: aws.String("test-session"),
|
||||
RoleArn: aws.String("arn:aws:iam::123456789012:role/test-role"),
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func assumeRoleQueryRequest(requestDuration time.Duration) makeRequest {
|
||||
return func(ctx context.Context, urlString string, region string, provider aws.CredentialsProvider, awsHost string) error {
|
||||
requestURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := requestURL.Query()
|
||||
query.Set("Action", "AssumeRole")
|
||||
query.Set("Version", "2011-06-15")
|
||||
query.Set("RoleArn", "arn:aws:iam::123456789012:role/test-role")
|
||||
query.Set("RoleSessionName", "test-session")
|
||||
query.Set("DurationSeconds", strconv.Itoa(int(requestDuration.Seconds())))
|
||||
requestURL.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), http.NoBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds, err := provider.Retrieve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := awsutils.NewSigner("sts").SignHTTP(ctx, creds, req, awsutils.EmptyPayloadHash, "sts", region, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Host = awsHost
|
||||
req.Header.Add("X-Forwarded-Host", awsHost)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return trace.BadParameter("unexpected status %v", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func assumeRoleQueryRequestByAssumedRole(requestDuration time.Duration) makeRequest {
|
||||
return func(ctx context.Context, urlString string, region string, provider aws.CredentialsProvider, awsHost string) error {
|
||||
requestURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := requestURL.Query()
|
||||
query.Set("Action", "AssumeRole")
|
||||
query.Set("Version", "2011-06-15")
|
||||
query.Set("RoleArn", "arn:aws:iam::123456789012:role/test-role")
|
||||
query.Set("RoleSessionName", "test-session")
|
||||
query.Set("DurationSeconds", strconv.Itoa(int(requestDuration.Seconds())))
|
||||
requestURL.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), http.NoBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
creds, err := provider.Retrieve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := awsutils.NewSigner("sts").SignHTTP(ctx, creds, req, awsutils.EmptyPayloadHash, "sts", region, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Host = awsHost
|
||||
req.Header.Add("X-Forwarded-Host", awsHost)
|
||||
req.Header.Add(common.TeleportAWSAssumedRole, fakeAssumedRoleARN)
|
||||
utils.RenameHeader(req.Header, awsutils.AuthorizationHeader, common.TeleportAWSAssumedRoleAuthorization)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return trace.BadParameter("unexpected status %v", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func assumeRoleMixedRequest(queryDuration, bodyDuration time.Duration) makeRequest {
|
||||
return func(ctx context.Context, urlString string, region string, provider aws.CredentialsProvider, awsHost string) error {
|
||||
requestURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := requestURL.Query()
|
||||
query.Set("Action", "AssumeRole")
|
||||
query.Set("Version", "2011-06-15")
|
||||
query.Set("RoleArn", "arn:aws:iam::123456789012:role/test-role")
|
||||
query.Set("RoleSessionName", "test-session")
|
||||
query.Set("DurationSeconds", strconv.Itoa(int(queryDuration.Seconds())))
|
||||
requestURL.RawQuery = query.Encode()
|
||||
|
||||
form := url.Values{
|
||||
"Action": {"AssumeRole"},
|
||||
"Version": {"2011-06-15"},
|
||||
"RoleArn": {"arn:aws:iam::123456789012:role/test-role"},
|
||||
"RoleSessionName": {"test-session"},
|
||||
"DurationSeconds": {strconv.Itoa(int(bodyDuration.Seconds()))},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
creds, err := provider.Retrieve(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadHash, err := awsutils.GetV4PayloadHash(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := awsutils.NewSigner("sts").SignHTTP(ctx, creds, req, payloadHash, "sts", region, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Host = awsHost
|
||||
req.Header.Add("X-Forwarded-Host", awsHost)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
return trace.BadParameter("unexpected status %v", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type requestByHTTPSProxy struct {
|
||||
xForwardedHost string
|
||||
}
|
||||
@@ -203,6 +362,13 @@ func hasStatusCode(wantStatusCode int) require.ErrorAssertionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnexpectedStatus(wantStatusCode int) require.ErrorAssertionFunc {
|
||||
return func(t require.TestingT, err error, msgAndArgs ...any) {
|
||||
require.Error(t, err, msgAndArgs...)
|
||||
require.Contains(t, err.Error(), strconv.Itoa(wantStatusCode)+" "+http.StatusText(wantStatusCode), msgAndArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAWSSignerHandler test the AWS SigningService APP handler logic with mocked STS signing credentials.
|
||||
func TestAWSSignerHandler(t *testing.T) {
|
||||
consoleApp, err := types.NewAppV3(types.Metadata{
|
||||
@@ -427,6 +593,40 @@ func TestAWSSignerHandler(t *testing.T) {
|
||||
require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole query success (shorter identity duration)",
|
||||
app: consoleApp,
|
||||
awsCredentialsProvider: staticAWSCredentialsForClient,
|
||||
awsRegion: "us-east-1",
|
||||
request: assumeRoleQueryRequest(12 * time.Hour),
|
||||
advanceClock: 30 * time.Minute,
|
||||
wantHost: "sts.amazonaws.com",
|
||||
wantAuthCredKeyID: "FAKEACCESSKEYID",
|
||||
wantAuthCredService: "sts",
|
||||
wantAuthCredRegion: "us-east-1",
|
||||
wantEventType: &events.AppSessionRequest{},
|
||||
verifySentRequest: verifyAssumeRoleDuration(30 * time.Minute), // 1h (suite default for identity) - 30m
|
||||
errAssertionFns: []require.ErrorAssertionFunc{
|
||||
require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole query and body success (shorter identity duration)",
|
||||
app: consoleApp,
|
||||
awsCredentialsProvider: staticAWSCredentialsForClient,
|
||||
awsRegion: "us-east-1",
|
||||
request: assumeRoleMixedRequest(12*time.Hour, 45*time.Minute),
|
||||
advanceClock: 30 * time.Minute,
|
||||
wantHost: "sts.amazonaws.com",
|
||||
wantAuthCredKeyID: "FAKEACCESSKEYID",
|
||||
wantAuthCredService: "sts",
|
||||
wantAuthCredRegion: "us-east-1",
|
||||
wantEventType: &events.AppSessionRequest{},
|
||||
verifySentRequest: verifyAssumeRoleDuration(30 * time.Minute), // 1h (suite default for identity) - 30m
|
||||
errAssertionFns: []require.ErrorAssertionFunc{
|
||||
require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole success (shorter requested duration)",
|
||||
app: consoleApp,
|
||||
@@ -443,6 +643,46 @@ func TestAWSSignerHandler(t *testing.T) {
|
||||
require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole by assumed role success (shorter requested duration)",
|
||||
app: consoleApp,
|
||||
awsCredentialsProvider: staticAWSCredentialsForAssumedRole,
|
||||
awsRegion: "us-east-1",
|
||||
request: assumeRoleRequestByAssumedRole(32 * time.Minute),
|
||||
wantHost: "sts.amazonaws.com",
|
||||
wantAuthCredKeyID: assumedRoleKeyID, // not using service's access key ID
|
||||
wantAuthCredService: "sts",
|
||||
wantAuthCredRegion: "us-east-1",
|
||||
wantEventType: &events.AppSessionRequest{},
|
||||
wantAssumedRole: fakeAssumedRoleARN, // verifies assumed role is recorded in audit
|
||||
skipVerifySignature: true, // not re-signing
|
||||
verifySentRequest: verifyAssumeRoleDuration(32 * time.Minute),
|
||||
errAssertionFns: []require.ErrorAssertionFunc{
|
||||
require.NoError,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole query by assumed role denied",
|
||||
app: consoleApp,
|
||||
awsCredentialsProvider: staticAWSCredentialsForAssumedRole,
|
||||
awsRegion: "us-east-1",
|
||||
request: assumeRoleQueryRequestByAssumedRole(2 * time.Hour),
|
||||
wantHost: "sts.amazonaws.com",
|
||||
errAssertionFns: []require.ErrorAssertionFunc{
|
||||
hasUnexpectedStatus(http.StatusForbidden),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole by assumed role denied",
|
||||
app: consoleApp,
|
||||
awsCredentialsProvider: staticAWSCredentialsForAssumedRole,
|
||||
awsRegion: "us-east-1",
|
||||
request: assumeRoleRequestByAssumedRole(2 * time.Hour),
|
||||
wantHost: "sts.amazonaws.com",
|
||||
errAssertionFns: []require.ErrorAssertionFunc{
|
||||
hasStatusCode(http.StatusForbidden),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AssumeRole denied",
|
||||
app: consoleApp,
|
||||
@@ -472,7 +712,6 @@ func TestAWSSignerHandler(t *testing.T) {
|
||||
assert.Equal(t, tc.wantAuthCredRegion, awsAuthHeader.Region)
|
||||
assert.Equal(t, tc.wantAuthCredKeyID, awsAuthHeader.KeyID)
|
||||
assert.Equal(t, tc.wantAuthCredService, awsAuthHeader.Service)
|
||||
|
||||
// check that the signature is valid.
|
||||
if !tc.skipVerifySignature {
|
||||
err := awsutils.VerifyAWSSignature(r,
|
||||
@@ -677,10 +916,10 @@ func createSuite(t *testing.T, mockAWSHandler http.HandlerFunc, app types.Applic
|
||||
|
||||
func verifyAssumeRoleDuration(wantDuration time.Duration) func(*testing.T, *http.Request) {
|
||||
return func(t *testing.T, req *http.Request) {
|
||||
clone, err := cloneRequest(req)
|
||||
assumeRoleReq, err := getAssumeRoleRequest(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, clone.ParseForm())
|
||||
require.Equal(t, wantDuration, getAssumeRoleQueryDuration(clone.PostForm))
|
||||
require.NotNil(t, assumeRoleReq)
|
||||
require.Equal(t, wantDuration, assumeRoleReq.getDuration())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user