Files
teleport/lib/utils/aws/aws.go
T
Gabriel Corado bcf1625f9a Remove AWS SDK V1 references (#52990)
* refactor: remove references to aws sdk v1

* refactor(aws): remove legacy error conversion for s3

* refactor(awsoidc): remove unused clientv1

* refactor: code review suggestions

* refactor: move xml helper functions into alpnproxy package

* chore(gomod): move into lambda direct dep

* chore: fix import order
2025-03-18 15:43:12 +00:00

552 lines
19 KiB
Go

/*
* Teleport
* Copyright (C) 2023 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 (
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"net/textproto"
"sort"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/aws/arn"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/gravitational/trace"
apievents "github.com/gravitational/teleport/api/types/events"
apiawsutils "github.com/gravitational/teleport/api/utils/aws"
"github.com/gravitational/teleport/lib/utils"
)
const (
// AmazonSigV4AuthorizationPrefix is AWS Authorization prefix indicating that the request
// was signed by AWS Signature Version 4.
// https://github.com/aws/aws-sdk-go/blob/main/aws/signer/v4/v4.go#L83
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html
AmazonSigV4AuthorizationPrefix = "AWS4-HMAC-SHA256"
// AmzDateTimeFormat is time format used in X-Amz-Date header.
// https://github.com/aws/aws-sdk-go/blob/main/aws/signer/v4/v4.go#L84
AmzDateTimeFormat = "20060102T150405Z"
// AmzDateHeader is header name containing timestamp when signature was generated.
// https://docs.aws.amazon.com/general/latest/gr/sigv4-date-handling.html
AmzDateHeader = "X-Amz-Date"
AuthorizationHeader = "Authorization"
credentialAuthHeaderElem = "Credential"
signedHeaderAuthHeaderElem = "SignedHeaders"
signatureAuthHeaderElem = "Signature"
// AmzTargetHeader is a header containing the API target.
// Format: target_version.operation
// Example: DynamoDB_20120810.Scan
AmzTargetHeader = "X-Amz-Target"
// AmzJSON1_0 is an AWS Content-Type header that indicates the media type is JSON.
AmzJSON1_0 = "application/x-amz-json-1.0"
// AmzJSON1_1 is an AWS Content-Type header that indicates the media type is JSON.
AmzJSON1_1 = "application/x-amz-json-1.1"
// MaxRoleSessionNameLength is the maximum length of the role session name
// used by the AssumeRole call.
// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html
MaxRoleSessionNameLength = 64
// EmptyPayloadHash is the SHA-256 for an empty element (as in echo -n | sha256sum).
// PresignHTTP requires the hash of the body, but when there is no body we hash the empty string.
// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
EmptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
iamServiceName = "iam"
)
// SigV4 contains parsed content of the AWS Authorization header.
type SigV4 struct {
// KeyIS is an AWS access-key-id
KeyID string
// Date value is specified using YYYYMMDD format.
Date string
// Region is an AWS Region.
Region string
// Service is an AWS Service.
Service string
// SignedHeaders is a list of request headers that you used to compute Signature.
SignedHeaders []string
// Signature is the 256-bit Signature of the request.
Signature string
}
// ParseSigV4 AWS SigV4 credentials string sections.
// AWS SigV4 header example below adds newlines for readability only - the real
// header must be a single continuous string with commas (and optional spaces)
// between the Credential, SignedHeaders, and Signature:
// Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,
// SignedHeaders=host;range;x-amz-date,
// Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
func ParseSigV4(header string) (*SigV4, error) {
if header == "" {
return nil, trace.BadParameter("empty AWS SigV4 header")
}
if !strings.HasPrefix(header, AmazonSigV4AuthorizationPrefix+" ") {
return nil, trace.BadParameter("missing AWS SigV4 authorization algorithm")
}
header = strings.TrimPrefix(header, AmazonSigV4AuthorizationPrefix+" ")
components := strings.Split(header, ",")
if len(components) != 3 {
return nil, trace.BadParameter("expected AWS SigV4 Authorization header with 3 comma-separated components but got %d", len(components))
}
m := make(map[string]string)
for _, v := range components {
kv := strings.Split(strings.Trim(v, " "), "=")
if len(kv) != 2 {
continue
}
m[kv[0]] = kv[1]
}
authParts := strings.Split(m[credentialAuthHeaderElem], "/")
if len(authParts) != 5 {
return nil, trace.BadParameter("invalid size of %q section", credentialAuthHeaderElem)
}
signature := m[signatureAuthHeaderElem]
if signature == "" {
return nil, trace.BadParameter("invalid signature")
}
var signedHeaders []string
if v := m[signedHeaderAuthHeaderElem]; v != "" {
signedHeaders = strings.Split(v, ";")
}
return &SigV4{
KeyID: authParts[0],
Date: authParts[1],
Region: authParts[2],
Service: authParts[3],
Signature: signature,
// Split semicolon-separated list of signed headers string.
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-auth-using-authorization-header.html
// https://github.com/aws/aws-sdk-go/blob/main/aws/signer/v4/v4.go#L631
SignedHeaders: signedHeaders,
}, nil
}
// IsSignedByAWSSigV4 checks is the request was signed by AWS Signature Version 4 algorithm.
// https://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html
func IsSignedByAWSSigV4(r *http.Request) bool {
return strings.HasPrefix(r.Header.Get(AuthorizationHeader), AmazonSigV4AuthorizationPrefix)
}
// VerifyAWSSignature verifies the request signature ensuring that the request originates from tsh aws command execution
// AWS CLI signs the request with random generated credentials that are passed to LocalProxy by
// the AWSCredentials LocalProxyConfig configuration.
func VerifyAWSSignature(req *http.Request, credProvider aws.CredentialsProvider) error {
sigV4, err := ParseSigV4(req.Header.Get("Authorization"))
if err != nil {
return trace.BadParameter("%s", err)
}
// Verifies the request is signed by the expected access key ID.
credValue, err := credProvider.Retrieve(req.Context())
if err != nil {
return trace.Wrap(err)
}
if sigV4.KeyID != credValue.AccessKeyID {
return trace.AccessDenied("AccessKeyID does not match")
}
// Skip signature verification if the incoming request includes the
// "User-Agent" header when making the signature. AWS Go SDK explicitly
// skips the "User-Agent" header so it will always produce a different
// signature. Only AccessKeyID is verified above in this case.
for _, signedHeader := range sigV4.SignedHeaders {
if strings.EqualFold(signedHeader, "User-Agent") {
return nil
}
}
// Read the request body and replace the body ready with a new reader that will allow reading the body again
// by HTTP Transport.
payload, err := utils.GetAndReplaceRequestBody(req)
if err != nil {
return trace.Wrap(err)
}
ctx := context.Background()
reqCopy := req.Clone(ctx)
// Remove all the headers that are not present in awsCred.SignedHeaders.
filterHeaders(reqCopy, sigV4.SignedHeaders)
// Get the date that was used to create the signature of the original request
// originated from AWS CLI and reuse it as a timestamp during request signing call.
t, err := time.Parse(AmzDateTimeFormat, reqCopy.Header.Get(AmzDateHeader))
if err != nil {
return trace.BadParameter("%s", err)
}
creds, err := credProvider.Retrieve(ctx)
if err != nil {
return trace.Wrap(err)
}
signer := NewSigner(sigV4.Service)
err = signer.SignHTTP(ctx, creds, reqCopy, GetV4PayloadHash(payload), sigV4.Service, sigV4.Region, t)
if err != nil {
return trace.Wrap(err)
}
localSigV4, err := ParseSigV4(reqCopy.Header.Get("Authorization"))
if err != nil {
return trace.Wrap(err)
}
// Compare the origin request AWS SigV4 signature with the signature calculated in LocalProxy based on
// AWSCredentials taken from LocalProxyConfig.
if sigV4.Signature != localSigV4.Signature {
return trace.AccessDenied("signature verification failed")
}
return nil
}
// NewSigner creates a new V4 signer.
func NewSigner(signingServiceName string) *v4.Signer {
return v4.NewSigner(func(opts *v4.SignerOptions) {
// s3 and s3control requests are signed with URL unescaped (found by
// searching "DisableURIPathEscaping" in "aws-sdk-go/service"). Both
// services use "s3" as signing name. See description of
// "DisableURIPathEscaping" for more details.
if signingServiceName == "s3" {
opts.DisableURIPathEscaping = true
}
})
}
// GetV4PayloadHash returns the V4 signing payload hash.
func GetV4PayloadHash(payload []byte) string {
if len(payload) == 0 {
return EmptyPayloadHash
}
hash := sha256.New()
hash.Write(payload)
return hex.EncodeToString(hash.Sum(nil))
}
// filterHeaders removes request headers that are not in the headers list and returns the removed header keys.
func filterHeaders(r *http.Request, headers []string) []string {
keep := make(map[string]struct{})
for _, key := range headers {
keep[textproto.CanonicalMIMEHeaderKey(key)] = struct{}{}
}
var removed []string
out := make(http.Header)
for key, vals := range r.Header {
if _, ok := keep[textproto.CanonicalMIMEHeaderKey(key)]; ok {
out[key] = vals
continue
}
removed = append(removed, key)
}
r.Header = out
return removed
}
// FilterAWSRoles returns role ARNs from the provided list that belong to the
// specified AWS account ID.
//
// If AWS account ID is empty, all valid AWS IAM roles are returned.
func FilterAWSRoles(arns []string, accountID string) (result Roles) {
for _, roleARN := range arns {
parsed, err := ParseRoleARN(roleARN)
if err != nil {
slog.WarnContext(context.Background(), "Skipping invalid AWS role ARN.", "error", err)
continue
}
if accountID != "" && parsed.AccountID != accountID {
continue
}
// In AWS convention, the display of the role is the last
// /-delineated substring.
//
// Example ARNs:
// arn:aws:iam::1234567890:role/EC2FullAccess (display: EC2FullAccess)
// arn:aws:iam::1234567890:role/path/to/customrole (display: customrole)
parts := strings.Split(parsed.Resource, "/")
result = append(result, Role{
Name: strings.Join(parts[1:], "/"),
Display: parts[len(parts)-1],
ARN: roleARN,
AccountID: parsed.AccountID,
})
}
return result
}
// Role describes an AWS IAM role for AWS console access.
type Role struct {
// Name is the full role name with the entire path.
Name string `json:"name"`
// Display is the role display name.
Display string `json:"display"`
// ARN is the full role ARN.
ARN string `json:"arn"`
// AccountID is the AWS Account ID this role refers to.
AccountID string `json:"accountId"`
}
// Roles is a slice of roles.
type Roles []Role
// Sort sorts the roles by their display names.
func (roles Roles) Sort() {
sort.SliceStable(roles, func(x, y int) bool {
return strings.ToLower(roles[x].Display) < strings.ToLower(roles[y].Display)
})
}
// FindRoleByARN finds the role with the provided ARN.
func (roles Roles) FindRoleByARN(arn string) (Role, bool) {
for _, role := range roles {
if role.ARN == arn {
return role, true
}
}
return Role{}, false
}
// FindRolesByName finds all roles matching the provided name.
func (roles Roles) FindRolesByName(name string) (result Roles) {
for _, role := range roles {
// Match either full name or the display name.
if role.Display == name || role.Name == name {
result = append(result, role)
}
}
return
}
// UnmarshalRequestBody reads and unmarshals a JSON request body into a protobuf Struct wrapper.
// If the request is not a recognized AWS JSON media type, or the body cannot be read, or the body
// is not valid JSON, then this function returns a nil value and an error.
// The protobuf Struct wrapper is useful for serializing JSON into a protobuf, because otherwise when the
// protobuf is marshaled it will re-marshall a JSON string field with escape characters or base64 encode
// a []byte field.
// Examples showing differences:
// - JSON string in proto: `{"Table": "some-table"}` --marshal to JSON--> `"{\"Table\": \"some-table\"}"`
// - bytes in proto: []byte --marshal to JSON--> `eyJUYWJsZSI6ICJzb21lLXRhYmxlIn0K` (base64 encoded)
// - *Struct in proto: *Struct --marshal to JSON--> `{"Table": "some-table"}` (unescaped JSON)
func UnmarshalRequestBody(req *http.Request) (*apievents.Struct, error) {
contentType := req.Header.Get("Content-Type")
if !isJSON(contentType) {
return nil, trace.BadParameter("invalid JSON request Content-Type: %q", contentType)
}
jsonBody, err := utils.GetAndReplaceRequestBody(req)
if err != nil {
return nil, trace.Wrap(err)
}
s := &apievents.Struct{}
if err := s.UnmarshalJSON(jsonBody); err != nil {
return nil, trace.Wrap(err)
}
return s, nil
}
// isJSON returns true if the Content-Type is recognized as standard JSON or any non-standard
// Amazon Content-Type header that indicates JSON media type.
func isJSON(contentType string) bool {
switch contentType {
case "application/json", AmzJSON1_0, AmzJSON1_1:
return true
default:
return false
}
}
// BuildRoleARN constructs a string AWS ARN from a username, region, and account ID.
// If username is an AWS ARN, this function checks that the ARN is an AWS IAM Role ARN
// in the correct partition and account.
func BuildRoleARN(username, region, accountID string) (string, error) {
partition := apiawsutils.GetPartitionFromRegion(region)
if arn.IsARN(username) {
// sanity check the given username role ARN.
parsed, err := ParseRoleARN(username)
if err != nil {
return "", trace.Wrap(err)
}
// don't check for empty accountID - callers do not always pass an account ID,
// and it's only absolutely required if we need to build the role ARN below.
if err := CheckARNPartitionAndAccount(parsed, partition, accountID); err != nil {
return "", trace.Wrap(err)
}
return username, nil
}
resource := username
if !IsPartialRoleARN(resource) {
resource = fmt.Sprintf("role/%s", username)
}
roleARN := arn.ARN{
Partition: partition,
Service: iamServiceName,
AccountID: accountID,
Resource: resource,
}
if err := apiawsutils.CheckRoleARN(roleARN.String()); err != nil {
return "", trace.Wrap(err)
}
return roleARN.String(), nil
}
// ValidateRoleARNAndExtractRoleName validates the role ARN and extracts the
// short role name from it.
func ValidateRoleARNAndExtractRoleName(roleARN, wantPartition, wantAccountID string) (string, error) {
role, err := ParseRoleARN(roleARN)
if err != nil {
return "", trace.Wrap(err)
}
if err := CheckARNPartitionAndAccount(role, wantPartition, wantAccountID); err != nil {
return "", trace.Wrap(err)
}
return strings.TrimPrefix(role.Resource, "role/"), nil
}
// ParseRoleARN parses an AWS ARN and checks that the ARN is
// for an IAM Role resource.
func ParseRoleARN(roleARN string) (*arn.ARN, error) {
role, err := arn.Parse(roleARN)
if err != nil {
return nil, trace.BadParameter("invalid AWS ARN: %v", err)
}
if err := checkRoleARN(&role); err != nil {
return nil, trace.Wrap(err)
}
return &role, nil
}
// checkRoleARN returns whether a parsed ARN is for an IAM Role resource.
// Example role ARN: arn:aws:iam::123456789012:role/some-role-name
func checkRoleARN(parsed *arn.ARN) error {
parts := strings.Split(parsed.Resource, "/")
if parts[0] != "role" || parsed.Service != iamServiceName {
return trace.BadParameter("%q is not an AWS IAM role ARN", parsed)
}
if len(parts) < 2 || len(parts[len(parts)-1]) == 0 {
return trace.BadParameter("%q is missing AWS IAM role name", parsed)
}
if err := apiawsutils.IsValidAccountID(parsed.AccountID); err != nil {
return trace.BadParameter("%q invalid account ID: %v", parsed, err)
}
return nil
}
// CheckARNPartitionAndAccount checks an AWS ARN against an expected AWS partition and account ID.
// An empty expected AWS partition or account ID is not checked.
func CheckARNPartitionAndAccount(ARN *arn.ARN, wantPartition, wantAccountID string) error {
if ARN.Partition != wantPartition && wantPartition != "" {
return trace.BadParameter("expected AWS partition %q but got %q", wantPartition, ARN.Partition)
}
if ARN.AccountID != wantAccountID && wantAccountID != "" {
return trace.BadParameter("expected AWS account ID %q but got %q", wantAccountID, ARN.AccountID)
}
return nil
}
// IsRoleARN returns true if the provided string is a AWS role ARN.
func IsRoleARN(roleARN string) bool {
if _, err := ParseRoleARN(roleARN); err == nil {
return true
}
return IsPartialRoleARN(roleARN)
}
// IsPartialRoleARN returns true if the provided role ARN only contains the
// resource name.
func IsPartialRoleARN(roleARN string) bool {
return strings.HasPrefix(roleARN, "role/")
}
// IsUserARN returns true if the provided string is a AWS user ARN.
func IsUserARN(userARN string) bool {
resourceName := userARN
if parsed, err := arn.Parse(userARN); err == nil {
resourceName = parsed.Resource
}
return strings.HasPrefix(resourceName, "user/")
}
// PolicyARN returns the ARN representation of an AWS IAM Policy.
func PolicyARN(partition, accountID, policy string) string {
return iamResourceARN(partition, accountID, "policy", policy)
}
// RoleARN returns the ARN representation of an AWS IAM Role.
func RoleARN(partition, accountID, role string) string {
return iamResourceARN(partition, accountID, "role", role)
}
func iamResourceARN(partition, accountID, resourceType, resourceName string) string {
return arn.ARN{
Partition: partition,
Service: "iam",
AccountID: accountID,
Resource: fmt.Sprintf("%s/%s", resourceType, resourceName),
}.String()
}
// MaybeHashRoleSessionName truncates the role session name and adds a hash
// when the original role session name is greater than AWS character limit
// (64).
func MaybeHashRoleSessionName(roleSessionName string) (ret string) {
if len(roleSessionName) <= MaxRoleSessionNameLength {
return roleSessionName
}
const hashLen = 16
hash := sha1.New()
hash.Write([]byte(roleSessionName))
hex := hex.EncodeToString(hash.Sum(nil))[:hashLen]
// "1" for the delimiter.
keepPrefixIndex := MaxRoleSessionNameLength - len(hex) - 1
// Sanity check. This should never happen since hash length and
// MaxRoleSessionNameLength are both constant.
if keepPrefixIndex < 0 {
keepPrefixIndex = 0
}
ret = fmt.Sprintf("%s-%s", roleSessionName[:keepPrefixIndex], hex)
slog.DebugContext(context.Background(), "AWS role session name is too long. Using a hash instead.", "hashed", ret, "original", roleSessionName)
return ret
}