Files
Josh Siviour 31eb3bc672 fix: fail a credential refresh instead of extending a dead key's expiry (#995)
ec2rolecreds implements aws.HandleFailRefreshCredentialsCacheStrategy, which
aws.CredentialsCache prefers over returning the refresh error. When the cached
credential is expired or within five minutes of expiring and the refresh fails,
it hands back the same access key, secret and session token with Expires pushed
a random five to fifteen minutes out, reported as a success. Every later
refresh fails the same way, so ecs-agent signs with a credential the control
plane has already rejected, indefinitely, and nothing surfaces it.

The cache picks its strategies by asserting on the provider's dynamic type, so
returning aws.CredentialsProvider from NewProvider does not disarm it. Wrap the
provider in a type whose method set omits HandleFailToRefresh, and the cache
falls through to returning the error. AdjustExpiresBy is forwarded on purpose:
it only fires under an ExpiryWindow, which no caller sets today, but dropping
it silently would change behaviour for whoever sets one.

Fixed in imdscreds rather than at the one exposed call site, so a future caller
that wraps this provider in a cache is safe by construction.
ecr-credential-provider was never affected: it calls Retrieve uncached.

Two comments claimed the hazard was ec2rolecreds capping Expires to an hour.
That cap only ever shortens a too-far-future expiry and is unrelated.
2026-09-04 12:00:31 +10:00

106 lines
4.0 KiB
Go

// Package imdscreds fetches AWS instance-role credentials from the in-guest
// IMDS endpoint (169.254.169.254) using IMDSv2. It is shared by the binaries
// that run inside a Spinifex guest and need the node/instance role credentials:
// the EKS ecr-credential-provider and the ECS ecs-agent.
//
// It wraps the AWS SDK's feature/ec2/imds client and credentials/ec2rolecreds
// provider rather than hand-rolling PUT-token/GET requests, so it inherits the
// SDK's Code-field validation, IMDSv1 fallback policy and strict RFC3339
// expiry parsing (a malformed Expiration is a hard error, not "never expires").
package imdscreds
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds"
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
)
// Credentials is a resolved instance-role credential set with parsed expiry.
type Credentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Expiration time.Time
}
// Fetch resolves the instance role then its credentials via the SDK's IMDSv2
// client and the ec2rolecreds provider. base is the IMDS root, e.g.
// http://169.254.169.254/latest.
func Fetch(client *http.Client, base string) (Credentials, error) {
creds, err := NewProvider(client, base).Retrieve(context.Background())
if err != nil {
return Credentials{}, fmt.Errorf("fetch IMDS credentials: %w", err)
}
if creds.AccessKeyID == "" || creds.SecretAccessKey == "" {
return Credentials{}, fmt.Errorf("IMDS credentials missing access/secret key")
}
return Credentials{
AccessKeyID: creds.AccessKeyID,
SecretAccessKey: creds.SecretAccessKey,
SessionToken: creds.SessionToken,
Expiration: creds.Expires,
}, nil
}
// NewProvider builds an ec2rolecreds provider against base, an IMDS root URL
// such as http://169.254.169.254/latest. A nil client uses the SDK IMDS
// client's default HTTP client.
func NewProvider(client *http.Client, base string) aws.CredentialsProvider {
imdsClient := NewClient(client, base)
return noExtendOnFailure{provider: ec2rolecreds.New(func(o *ec2rolecreds.Options) {
o.Client = imdsClient
})}
}
// noExtendOnFailure forwards everything ec2rolecreds offers except
// HandleFailToRefresh, which answers a failed refresh by returning the same
// expired key with a fresh Expires so a caller never learns the refresh failed.
type noExtendOnFailure struct {
provider *ec2rolecreds.Provider
}
// aws.CredentialsCache picks its strategies by asserting on the provider's
// dynamic type, so hiding the method is what disarms it — returning an
// interface from NewProvider is not enough.
var (
_ aws.CredentialsProvider = noExtendOnFailure{}
_ aws.AdjustExpiresByCredentialsCacheStrategy = noExtendOnFailure{}
)
func (p noExtendOnFailure) Retrieve(ctx context.Context) (aws.Credentials, error) {
return p.provider.Retrieve(ctx)
}
// AdjustExpiresBy is forwarded deliberately. It only fires when the cache is
// built with an ExpiryWindow, which no caller does today, but dropping it
// silently would change that caller's behaviour without them touching this.
func (p noExtendOnFailure) AdjustExpiresBy(creds aws.Credentials, dur time.Duration) (aws.Credentials, error) {
return p.provider.AdjustExpiresBy(creds, dur)
}
// NewClient builds an SDK IMDS client against base, an IMDS root URL such as
// http://169.254.169.254/latest. A trailing /latest is stripped since the
// client appends its own; both forms of base keep working. A nil client uses
// the SDK IMDS client's default HTTP client.
func NewClient(client *http.Client, base string) *imds.Client {
opts := imds.Options{Endpoint: rootEndpoint(base)}
if client != nil {
opts.HTTPClient = client
}
return imds.New(opts)
}
// rootEndpoint strips a trailing /latest from base so it can be used as the
// SDK IMDS client's Endpoint, which is the scheme+host root — the client
// prepends its own /latest to every request path.
func rootEndpoint(base string) string {
base = strings.TrimRight(base, "/")
return strings.TrimSuffix(base, "/latest")
}