mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: presigned urls (#533)
This commit is contained in:
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
"github.com/mulgadc/predastore/pkg/sigv4"
|
||||
)
|
||||
|
||||
// failingProvider models a cold IMDS datapath: Retrieve always errors, as the
|
||||
@@ -40,13 +40,14 @@ func TestSign_StaticVerifies(t *testing.T) {
|
||||
|
||||
var verifyErr error
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := auth.ParseReq(r)
|
||||
// sigv4.Parse reads and hashes the body itself, so no explicit body hash.
|
||||
sr, err := sigv4.Parse(r)
|
||||
if err != nil {
|
||||
verifyErr = err
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
verifyErr = req.Verify(testSecretKey, testService, testRegion, auth.WithBodyHash(payloadHash))
|
||||
_, verifyErr = sr.Verify(testSecretKey, testRegion, testService)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
+47
-64
@@ -3,8 +3,6 @@ package gateway
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -13,15 +11,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
"github.com/mulgadc/predastore/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
)
|
||||
|
||||
// Maximum request body size for signature validation (10 MB).
|
||||
const maxBodySize = 10 * 1024 * 1024
|
||||
|
||||
// AKID prefixes. Prefix-first dispatch prevents a misfiled record from being
|
||||
// resolved by the wrong lookup path.
|
||||
const (
|
||||
@@ -42,15 +37,36 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
sig, err := auth.ParseReq(r)
|
||||
// sigv4.Parse reads, hashes, and rewinds the request body to build the
|
||||
// canonical request; Verify below only recomputes the HMAC.
|
||||
sig, err := sigv4.Parse(r)
|
||||
if err != nil {
|
||||
gw.writeSigV4Error(w, r, parseSigV4ErrorCode(err))
|
||||
// sigv4 validates the envelope, credential scope, and timestamp at
|
||||
// parse time. Distinguish a request that never presented credentials
|
||||
// (or is simply too large) from a failed authentication attempt: the
|
||||
// latter is rate-limited so a brute-forcer is locked out regardless of
|
||||
// which validation stage rejects it.
|
||||
switch {
|
||||
case errors.Is(err, sigv4.ErrMissingAuthentication):
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken)
|
||||
case errors.Is(err, sigv4.ErrPayloadTooLarge):
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorRequestEntityTooLarge)
|
||||
case errors.Is(err, sigv4.ErrRequestTimeTooSkewed):
|
||||
// Skew/replay: AWS returns this as SignatureDoesNotMatch.
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
default:
|
||||
// Malformed Authorization, bad credential scope, unsupported
|
||||
// algorithm, missing content hash: a failed auth attempt.
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Reject unknown services before crypto; otherwise Verify re-signs with
|
||||
// the client-claimed service name and rubber-stamps the scope.
|
||||
if !supportedServices[sig.Service] {
|
||||
if !supportedServices[sig.Credential.Service] {
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
return
|
||||
}
|
||||
@@ -58,7 +74,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
// Fail fast when NATS is down; LookupAccessKey would hang 5 s otherwise.
|
||||
// Nil NATSConn (test helpers) skips this check.
|
||||
if gw.NATSConn != nil && !gw.NATSConn.IsConnected() {
|
||||
gw.writeClusterUnavailable(w, r, sig.Service)
|
||||
gw.writeClusterUnavailable(w, r, sig.Credential.Service)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,12 +90,12 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
lookupCode string
|
||||
)
|
||||
switch {
|
||||
case strings.HasPrefix(sig.AccessKeyID, longLivedAKIDPrefix):
|
||||
secret, principal, lookupCode = gw.resolveLongLivedAKID(sig.AccessKeyID, clientIP)
|
||||
case strings.HasPrefix(sig.AccessKeyID, sessionAKIDPrefix):
|
||||
secret, principal, lookupCode = gw.resolveSessionAKID(r, sig.AccessKeyID, clientIP)
|
||||
case strings.HasPrefix(sig.Credential.AccessKeyID, longLivedAKIDPrefix):
|
||||
secret, principal, lookupCode = gw.resolveLongLivedAKID(sig.Credential.AccessKeyID, clientIP)
|
||||
case strings.HasPrefix(sig.Credential.AccessKeyID, sessionAKIDPrefix):
|
||||
secret, principal, lookupCode = gw.resolveSessionAKID(r, sig.Credential.AccessKeyID, clientIP)
|
||||
default:
|
||||
slog.Warn("Auth failure: unknown AKID prefix", "accessKeyID", sig.AccessKeyID, "sourceIP", clientIP)
|
||||
slog.Warn("Auth failure: unknown AKID prefix", "accessKeyID", sig.Credential.AccessKeyID, "sourceIP", clientIP)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId)
|
||||
return
|
||||
@@ -89,47 +105,32 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
// Region is pinned to the gateway; service is the client-claimed scope,
|
||||
// already gated against supportedServices above.
|
||||
if _, err := sig.Verify(secret, gw.Region, sig.Credential.Service); err != nil {
|
||||
slog.Warn("Auth failure: verification failed",
|
||||
"accessKeyID", sig.Credential.AccessKeyID, "sourceIP", clientIP, "err", err)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse rewound the body; re-read it for query-arg parsing, then rewind
|
||||
// again for the downstream handler.
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
slog.Warn("Request body too large", "limit", maxBodySize)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
slog.Error("Failed to read request body", "err", err)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInternalError)
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
sum := sha256.Sum256(body)
|
||||
bodyHash := hex.EncodeToString(sum[:])
|
||||
|
||||
if err := sig.Verify(secret, sig.Service, gw.Region,
|
||||
auth.WithBodyHash(bodyHash)); err != nil {
|
||||
attrs := []any{
|
||||
"accessKeyID", sig.AccessKeyID,
|
||||
"sourceIP", clientIP,
|
||||
"err", err,
|
||||
}
|
||||
var sme *auth.SigMismatchError
|
||||
if errors.As(err, &sme) {
|
||||
attrs = append(attrs, "expectedAuthHdr", sme.ExpectedAuthHdr, "providedAuthHdr", sme.ProvidedAuthHdr)
|
||||
}
|
||||
slog.Warn("Auth failure: verification failed", attrs...)
|
||||
gw.RateLimiter.RecordFailure(clientIP)
|
||||
gw.writeSigV4Error(w, r, verifySigV4ErrorCode(err))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, ctxIdentity, principal.identity)
|
||||
ctx = context.WithValue(ctx, ctxAccountID, principal.accountID)
|
||||
ctx = context.WithValue(ctx, ctxService, sig.Service)
|
||||
ctx = context.WithValue(ctx, ctxRegion, sig.Region)
|
||||
ctx = context.WithValue(ctx, ctxAccessKey, sig.AccessKeyID)
|
||||
ctx = context.WithValue(ctx, ctxService, sig.Credential.Service)
|
||||
ctx = context.WithValue(ctx, ctxRegion, sig.Credential.Region)
|
||||
ctx = context.WithValue(ctx, ctxAccessKey, sig.Credential.AccessKeyID)
|
||||
ctx = context.WithValue(ctx, ctxPrincipalType, principal.principalType)
|
||||
if principal.assumedRoleARN != "" {
|
||||
ctx = context.WithValue(ctx, ctxAssumedRoleARN, principal.assumedRoleARN)
|
||||
@@ -151,7 +152,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
slog.Debug("SigV4 authentication successful",
|
||||
"accessKey", sig.AccessKeyID,
|
||||
"accessKey", sig.Credential.AccessKeyID,
|
||||
"identity", principal.identity,
|
||||
"principalType", principal.principalType)
|
||||
gw.RateLimiter.RecordSuccess(clientIP)
|
||||
@@ -269,24 +270,6 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client
|
||||
}, ""
|
||||
}
|
||||
|
||||
func parseSigV4ErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrMissingAuth):
|
||||
return awserrors.ErrorMissingAuthenticationToken
|
||||
default:
|
||||
return awserrors.ErrorIncompleteSignature
|
||||
}
|
||||
}
|
||||
|
||||
func verifySigV4ErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrMissingContentSHA):
|
||||
return awserrors.ErrorIncompleteSignature
|
||||
default:
|
||||
return awserrors.ErrorSignatureDoesNotMatch
|
||||
}
|
||||
}
|
||||
|
||||
// writeSigV4Error writes an EC2-compatible XML error response for auth failures.
|
||||
func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, errorCode string) {
|
||||
requestID := uuid.NewString()
|
||||
|
||||
@@ -2,6 +2,7 @@ package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -14,8 +15,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
"github.com/mulgadc/predastore/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
@@ -67,13 +70,24 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// signTestRequest signs req in place using SigV4. body must match what the
|
||||
// middleware will read from r.Body so the sha256 matches X-Amz-Content-Sha256.
|
||||
func signTestRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret string, optFns ...func(*auth.Options)) {
|
||||
// signTestRequest signs req in place with the aws-sdk-go-v2 v4 signer — the same
|
||||
// path production callers use via gwsign — so the gateway verifies a real SDK
|
||||
// signature. body must match what the middleware reads from r.Body so the payload
|
||||
// hash agrees. An optional signingTime pins the clock for skew tests.
|
||||
func signTestRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret string, signingTime ...time.Time) {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(body)
|
||||
require.NoError(t, auth.SignReq(req, accessKey, secret,
|
||||
hex.EncodeToString(sum[:]), testService, testRegion, optFns...))
|
||||
payloadHash := hex.EncodeToString(sum[:])
|
||||
when := time.Now().UTC()
|
||||
if len(signingTime) > 0 {
|
||||
when = signingTime[0]
|
||||
}
|
||||
// gwsign sets this header so the SDK signs it; sigv4 reproduces it when
|
||||
// reconstructing the canonical request.
|
||||
req.Header.Set("X-Amz-Content-Sha256", payloadHash)
|
||||
require.NoError(t, v4.NewSigner().SignHTTP(context.Background(),
|
||||
aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secret},
|
||||
req, payloadHash, testService, testRegion, when))
|
||||
}
|
||||
|
||||
func setupTestApp(accessKey, secretKey string) http.Handler {
|
||||
@@ -410,7 +424,7 @@ func TestSigV4Auth_DecryptFailure(t *testing.T) {
|
||||
func TestSigV4Auth_RequestBodyTooLarge(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
oversizedBody := []byte(strings.Repeat("x", maxBodySize+1))
|
||||
oversizedBody := []byte(strings.Repeat("x", sigv4.MaxPayloadLen+1))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversizedBody))
|
||||
req.Host = "localhost:9999"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
@@ -433,10 +447,10 @@ func TestSigV4Auth_RequestBodyTooLarge(t *testing.T) {
|
||||
func TestSigV4Auth_ExpiredTimestamp(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
past := time.Now().UTC().Add(-6 * time.Minute) // exceeds 5-minute maxClockSkew
|
||||
past := time.Now().UTC().Add(-16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(past))
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, past)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
|
||||
@@ -453,10 +467,10 @@ func TestSigV4Auth_ExpiredTimestamp(t *testing.T) {
|
||||
func TestSigV4Auth_FutureTimestamp(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
future := time.Now().UTC().Add(6 * time.Minute) // exceeds 5-minute maxClockSkew
|
||||
future := time.Now().UTC().Add(16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(future))
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, future)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
|
||||
@@ -473,10 +487,10 @@ func TestSigV4Auth_FutureTimestamp(t *testing.T) {
|
||||
func TestSigV4Auth_TimestampWithinSkew(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
recent := time.Now().UTC().Add(-4 * time.Minute) // within the 5-minute window
|
||||
recent := time.Now().UTC().Add(-14 * time.Minute) // within the 15-minute window
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(recent))
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, recent)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
|
||||
@@ -496,11 +510,11 @@ func TestSigV4Auth_ClockSkewBoundary(t *testing.T) {
|
||||
offset time.Duration
|
||||
expectPass bool
|
||||
}{
|
||||
// 4m59s not 5m to absorb test execution time.
|
||||
{"just within 5 min past", -(4*time.Minute + 59*time.Second), true},
|
||||
{"just beyond 5 min past", -(5*time.Minute + 1*time.Second), false},
|
||||
{"just within 5 min future", 4*time.Minute + 59*time.Second, true},
|
||||
{"just beyond 5 min future", 5*time.Minute + 1*time.Second, false},
|
||||
// 14m59s not 15m to absorb test execution time.
|
||||
{"just within 15 min past", -(14*time.Minute + 59*time.Second), true},
|
||||
{"just beyond 15 min past", -(15*time.Minute + 1*time.Second), false},
|
||||
{"just within 15 min future", 14*time.Minute + 59*time.Second, true},
|
||||
{"just beyond 15 min future", 15*time.Minute + 1*time.Second, false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -508,7 +522,7 @@ func TestSigV4Auth_ClockSkewBoundary(t *testing.T) {
|
||||
at := time.Now().UTC().Add(tc.offset)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Host = "localhost:9999"
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(at))
|
||||
signTestRequest(t, req, nil, testAccessKey, testSecretKey, at)
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
package handlers_sts
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/predastore/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
const (
|
||||
presignedAlgorithm = "AWS4-HMAC-SHA256"
|
||||
presignedTerminator = "aws4_request"
|
||||
presignedService = "sts"
|
||||
presignedAmzDateFmt = "20060102T150405Z"
|
||||
presignedDateFmt = "20060102"
|
||||
presignedSignedHeader = "x-k8s-aws-id"
|
||||
presignedHostHeader = "host"
|
||||
|
||||
// presignedMaxExpiresSeconds is the AWS-imposed ceiling on X-Amz-Expires.
|
||||
// Rejecting beyond this ceiling catches forged URLs that lie about their TTL.
|
||||
presignedMaxExpiresSeconds = 7 * 24 * 60 * 60
|
||||
|
||||
// presignedClockSkew is the SigV4 wire-protocol clock-skew allowance.
|
||||
presignedClockSkew = 5 * time.Minute
|
||||
)
|
||||
|
||||
// PresignedCallerIdentity is the result of validating a SigV4-presigned GetCallerIdentity
|
||||
@@ -54,7 +36,8 @@ var presignedTimeNow = func() time.Time { return time.Now().UTC() }
|
||||
|
||||
// VerifyPresignedGetCallerIdentity validates a SigV4-presigned GetCallerIdentity URL
|
||||
// and resolves the calling principal. Called by the EKS token webhook over NATS.
|
||||
// Reconstructs the canonical request with expectedClusterName to prevent cross-cluster replay.
|
||||
// The X-K8s-Aws-Id header is reconstructed from expectedClusterName rather than the URL,
|
||||
// so a URL presigned for one cluster produces a signature mismatch against another.
|
||||
func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expectedClusterName string) (*PresignedCallerIdentity, error) {
|
||||
if presignedURL == "" || expectedClusterName == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
@@ -68,73 +51,42 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
q := u.Query()
|
||||
env, err := parsePresignedEnvelope(q)
|
||||
// Reconstruct the signed request, binding X-K8s-Aws-Id to the expected cluster.
|
||||
// sigv4.Parse reads the header value off the request, so an attacker's URL signed
|
||||
// for another cluster reconstructs under a different value and fails the compare.
|
||||
r, err := http.NewRequest(http.MethodGet, presignedURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
// Canonical MIME key on Set; sigv4 lowercases header names for the SignedHeaders set.
|
||||
r.Header.Set("X-K8s-Aws-Id", expectedClusterName)
|
||||
|
||||
req, err := sigv4.Parse(r, sigv4.WithTime(presignedTimeNow()))
|
||||
if err != nil {
|
||||
slog.Warn("VerifyPresignedGetCallerIdentity: envelope parse failed", "err", err)
|
||||
return nil, mapSigv4Err(err)
|
||||
}
|
||||
|
||||
// sigv4 enforces host is signed but not X-K8s-Aws-Id; without it the reconstructed
|
||||
// canonical request drops the cluster binding, so verify would self-match any URL.
|
||||
if _, ok := req.Canonical.SignedHeaders[presignedSignedHeader]; !ok {
|
||||
slog.Warn("VerifyPresignedGetCallerIdentity: x-k8s-aws-id not in SignedHeaders")
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
if !env.signedHeaders[presignedSignedHeader] {
|
||||
slog.Warn("VerifyPresignedGetCallerIdentity: x-k8s-aws-id not in SignedHeaders",
|
||||
"signed_headers", env.rawSignedHeaders)
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
if !env.signedHeaders[presignedHostHeader] {
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
if env.credential.service != presignedService {
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
if env.credential.terminator != presignedTerminator {
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
now := presignedTimeNow()
|
||||
if now.Sub(env.signedTime).Abs() > presignedClockSkew+time.Duration(env.expiresSeconds)*time.Second {
|
||||
// Guards a nonsense X-Amz-Date (months out); exact expiry is checked below.
|
||||
return nil, errors.New(awserrors.ErrorExpiredToken)
|
||||
}
|
||||
if now.After(env.signedTime.Add(time.Duration(env.expiresSeconds) * time.Second)) {
|
||||
return nil, errors.New(awserrors.ErrorExpiredToken)
|
||||
}
|
||||
if env.signedTime.UTC().Format(presignedDateFmt) != env.credential.date {
|
||||
// Credential-scope date must match the X-Amz-Date date portion.
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
principal, secret, err := s.resolvePrincipalForVerify(env.credential.accessKeyID)
|
||||
principal, secret, err := s.resolvePrincipalForVerify(req.Credential.AccessKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
canonicalQuery := canonicalQueryStringExcludingSignature(q)
|
||||
canonicalHeaders, signedHeadersList := buildCanonicalHeaders(u.Host, expectedClusterName)
|
||||
canonicalRequest := strings.Join([]string{
|
||||
"GET",
|
||||
canonicalURI(u),
|
||||
canonicalQuery,
|
||||
canonicalHeaders,
|
||||
signedHeadersList,
|
||||
// Non-S3 presigned requests use SHA256("") as the payload hash; "UNSIGNED-PAYLOAD" is S3-only.
|
||||
emptyStringSHA256,
|
||||
}, "\n")
|
||||
|
||||
stringToSign := strings.Join([]string{
|
||||
presignedAlgorithm,
|
||||
env.amzDate,
|
||||
env.credential.scope(),
|
||||
hexSHA256(canonicalRequest),
|
||||
}, "\n")
|
||||
|
||||
signingKey := deriveSigningKey(secret, env.credential.date, env.credential.region, env.credential.service)
|
||||
expectedSig := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(env.signature)) != 1 {
|
||||
slog.Warn("VerifyPresignedGetCallerIdentity: signature mismatch (cluster name replay or tampered URL)",
|
||||
"akid", env.credential.accessKeyID,
|
||||
"expected_cluster", expectedClusterName)
|
||||
// Pass the credential's own region back so Verify's region check is a no-op:
|
||||
// `aws eks get-token` signs against the caller's regional STS endpoint, which we
|
||||
// do not constrain. The service is pinned to sts.
|
||||
if _, err := req.Verify(secret, req.Credential.Region, presignedService); err != nil {
|
||||
slog.Warn("VerifyPresignedGetCallerIdentity: signature verify failed (cluster name replay or tampered URL)",
|
||||
"akid", req.Credential.AccessKeyID,
|
||||
"expected_cluster", expectedClusterName,
|
||||
"err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
|
||||
@@ -142,195 +94,15 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
// presignedEnvelope holds the parsed envelope facts from a SigV4-presigned URL query string.
|
||||
type presignedEnvelope struct {
|
||||
credential presignedCredential
|
||||
amzDate string // raw X-Amz-Date value (used in StringToSign)
|
||||
signedTime time.Time
|
||||
expiresSeconds int64
|
||||
rawSignedHeaders string
|
||||
signedHeaders map[string]bool
|
||||
signature string
|
||||
}
|
||||
|
||||
type presignedCredential struct {
|
||||
accessKeyID string
|
||||
date string // YYYYMMDD
|
||||
region string
|
||||
service string
|
||||
terminator string
|
||||
}
|
||||
|
||||
func (c presignedCredential) scope() string {
|
||||
return strings.Join([]string{c.date, c.region, c.service, c.terminator}, "/")
|
||||
}
|
||||
|
||||
func parsePresignedEnvelope(q url.Values) (presignedEnvelope, error) {
|
||||
algo := q.Get("X-Amz-Algorithm")
|
||||
if algo != presignedAlgorithm {
|
||||
return presignedEnvelope{}, fmt.Errorf("unsupported algorithm %q", algo)
|
||||
// mapSigv4Err maps sigv4 sentinel errors onto AWS error codes. Time-window failures
|
||||
// surface as ExpiredToken; every other structural failure is an invalid token.
|
||||
func mapSigv4Err(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, sigv4.ErrPresignedURLExpired), errors.Is(err, sigv4.ErrRequestTimeTooSkewed):
|
||||
return errors.New(awserrors.ErrorExpiredToken)
|
||||
default:
|
||||
return errors.New(awserrors.ErrorInvalidIdentityToken)
|
||||
}
|
||||
credRaw := q.Get("X-Amz-Credential")
|
||||
if credRaw == "" {
|
||||
return presignedEnvelope{}, errors.New("missing X-Amz-Credential")
|
||||
}
|
||||
parts := strings.Split(credRaw, "/")
|
||||
if len(parts) != 5 {
|
||||
return presignedEnvelope{}, fmt.Errorf("X-Amz-Credential has %d parts, expected 5", len(parts))
|
||||
}
|
||||
cred := presignedCredential{
|
||||
accessKeyID: parts[0],
|
||||
date: parts[1],
|
||||
region: parts[2],
|
||||
service: parts[3],
|
||||
terminator: parts[4],
|
||||
}
|
||||
if cred.accessKeyID == "" {
|
||||
return presignedEnvelope{}, errors.New("X-Amz-Credential has empty access key id")
|
||||
}
|
||||
|
||||
amzDate := q.Get("X-Amz-Date")
|
||||
if amzDate == "" {
|
||||
return presignedEnvelope{}, errors.New("missing X-Amz-Date")
|
||||
}
|
||||
signedTime, err := time.Parse(presignedAmzDateFmt, amzDate)
|
||||
if err != nil {
|
||||
return presignedEnvelope{}, fmt.Errorf("invalid X-Amz-Date: %w", err)
|
||||
}
|
||||
|
||||
expiresRaw := q.Get("X-Amz-Expires")
|
||||
if expiresRaw == "" {
|
||||
return presignedEnvelope{}, errors.New("missing X-Amz-Expires")
|
||||
}
|
||||
expires, err := strconv.ParseInt(expiresRaw, 10, 64)
|
||||
if err != nil || expires <= 0 || expires > presignedMaxExpiresSeconds {
|
||||
return presignedEnvelope{}, fmt.Errorf("invalid X-Amz-Expires: %q", expiresRaw)
|
||||
}
|
||||
|
||||
signedHeadersRaw := q.Get("X-Amz-SignedHeaders")
|
||||
if signedHeadersRaw == "" {
|
||||
return presignedEnvelope{}, errors.New("missing X-Amz-SignedHeaders")
|
||||
}
|
||||
signedSet := make(map[string]bool)
|
||||
for h := range strings.SplitSeq(signedHeadersRaw, ";") {
|
||||
signedSet[strings.ToLower(h)] = true
|
||||
}
|
||||
|
||||
sig := q.Get("X-Amz-Signature")
|
||||
if sig == "" {
|
||||
return presignedEnvelope{}, errors.New("missing X-Amz-Signature")
|
||||
}
|
||||
|
||||
return presignedEnvelope{
|
||||
credential: cred,
|
||||
amzDate: amzDate,
|
||||
signedTime: signedTime.UTC(),
|
||||
expiresSeconds: expires,
|
||||
rawSignedHeaders: signedHeadersRaw,
|
||||
signedHeaders: signedSet,
|
||||
signature: sig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// canonicalQueryStringExcludingSignature returns the SigV4 canonical query string
|
||||
// with X-Amz-Signature dropped, per-RFC-3986 encoded, sorted by key then value.
|
||||
func canonicalQueryStringExcludingSignature(q url.Values) string {
|
||||
type kv struct {
|
||||
k string
|
||||
v string
|
||||
}
|
||||
pairs := make([]kv, 0, len(q))
|
||||
for k, vs := range q {
|
||||
if k == "X-Amz-Signature" {
|
||||
continue
|
||||
}
|
||||
ek := awsQueryEscape(k)
|
||||
for _, v := range vs {
|
||||
pairs = append(pairs, kv{ek, awsQueryEscape(v)})
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].k == pairs[j].k {
|
||||
return pairs[i].v < pairs[j].v
|
||||
}
|
||||
return pairs[i].k < pairs[j].k
|
||||
})
|
||||
parts := make([]string, len(pairs))
|
||||
for i, p := range pairs {
|
||||
parts[i] = p.k + "=" + p.v
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// awsQueryEscape encodes per SigV4 (unreserved set, uppercase hex).
|
||||
// net/url.QueryEscape encodes spaces as '+' and lower-cases hex, both diverging from AWS.
|
||||
func awsQueryEscape(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'),
|
||||
c == '-' || c == '_' || c == '.' || c == '~':
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteByte('%')
|
||||
b.WriteString(strings.ToUpper(hexByte(c)))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hexByte(b byte) string {
|
||||
const hexDigits = "0123456789ABCDEF"
|
||||
return string([]byte{hexDigits[b>>4], hexDigits[b&0x0f]})
|
||||
}
|
||||
|
||||
// canonicalURI returns the SigV4 canonical URI; empty path normalises to "/".
|
||||
func canonicalURI(u *url.URL) string {
|
||||
if u.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// buildCanonicalHeaders reconstructs the canonical-headers/signed-headers pair for
|
||||
// the presigned URL, using the URL host and expectedClusterName for X-K8s-Aws-Id.
|
||||
func buildCanonicalHeaders(host, xK8sAwsID string) (canonicalHeaders, signedHeadersList string) {
|
||||
headers := map[string]string{
|
||||
presignedHostHeader: host,
|
||||
presignedSignedHeader: xK8sAwsID,
|
||||
}
|
||||
names := slices.Sorted(maps.Keys(headers))
|
||||
var b strings.Builder
|
||||
for _, n := range names {
|
||||
b.WriteString(n)
|
||||
b.WriteByte(':')
|
||||
b.WriteString(strings.TrimSpace(headers[n]))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String(), strings.Join(names, ";")
|
||||
}
|
||||
|
||||
func hexSHA256(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// emptyStringSHA256 is the SigV4 payload hash for an empty body.
|
||||
var emptyStringSHA256 = hexSHA256("")
|
||||
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write([]byte(data))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func deriveSigningKey(secret, date, region, service string) []byte {
|
||||
kDate := hmacSHA256([]byte("AWS4"+secret), date)
|
||||
kRegion := hmacSHA256(kDate, region)
|
||||
kService := hmacSHA256(kRegion, service)
|
||||
return hmacSHA256(kService, presignedTerminator)
|
||||
}
|
||||
|
||||
// resolvePrincipalForVerify resolves an access key to its plaintext secret and a
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package handlers_sts
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -24,76 +21,22 @@ const (
|
||||
testPresignedRegion = "au-mel-1"
|
||||
)
|
||||
|
||||
// presignTestURL builds an `aws eks get-token`-shape SigV4-presigned GET URL
|
||||
// for sts.GetCallerIdentity, signing the X-K8s-Aws-Id header with the given
|
||||
// clusterName so a Verify call against the same clusterName succeeds. Hand-
|
||||
// rolled rather than via aws-sdk-go-v2 so the test does not pull an extra
|
||||
// dependency just to assert the production reconstruction.
|
||||
// presignTestURL builds an `aws eks get-token`-shape SigV4-presigned GET URL for
|
||||
// sts.GetCallerIdentity using the real aws-sdk-go v4 presigner — the same
|
||||
// canonicalisation `aws eks get-token` uses — signing the X-K8s-Aws-Id header with
|
||||
// the given clusterName so a Verify call against the same clusterName succeeds.
|
||||
func presignTestURL(t *testing.T, accessKeyID, secret, clusterName string, signedTime time.Time, expires int64) string {
|
||||
t.Helper()
|
||||
host := testPresignedHost
|
||||
region := testPresignedRegion
|
||||
service := "sts"
|
||||
terminator := "aws4_request"
|
||||
date := signedTime.UTC().Format("20060102")
|
||||
amzDate := signedTime.UTC().Format("20060102T150405Z")
|
||||
credentialScope := strings.Join([]string{date, region, service, terminator}, "/")
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"https://"+testPresignedHost+"/?Action=GetCallerIdentity&Version=2011-06-15", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-K8s-Aws-Id", clusterName)
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("Action", "GetCallerIdentity")
|
||||
q.Set("Version", "2011-06-15")
|
||||
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
q.Set("X-Amz-Credential", accessKeyID+"/"+credentialScope)
|
||||
q.Set("X-Amz-Date", amzDate)
|
||||
q.Set("X-Amz-Expires", strconv.FormatInt(expires, 10))
|
||||
q.Set("X-Amz-SignedHeaders", "host;x-k8s-aws-id")
|
||||
signer := v4.NewSigner(credentials.NewStaticCredentials(accessKeyID, secret, ""))
|
||||
_, err = signer.Presign(req, nil, presignedService, testPresignedRegion, time.Duration(expires)*time.Second, signedTime)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Canonical query string (no signature yet) — must match the
|
||||
// production reconstruction, byte-for-byte.
|
||||
canonQ := canonicalQueryStringForTest(q)
|
||||
|
||||
canonHeaders := "host:" + host + "\n" + "x-k8s-aws-id:" + clusterName + "\n"
|
||||
signedHeadersList := "host;x-k8s-aws-id"
|
||||
canonRequest := strings.Join([]string{
|
||||
"GET", "/", canonQ, canonHeaders, signedHeadersList, emptyStringSHA256,
|
||||
}, "\n")
|
||||
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256", amzDate, credentialScope, hexSHA256(canonRequest),
|
||||
}, "\n")
|
||||
|
||||
key := deriveSigningKey(secret, date, region, service)
|
||||
sig := hex.EncodeToString(hmacSHA256(key, stringToSign))
|
||||
q.Set("X-Amz-Signature", sig)
|
||||
|
||||
return "https://" + host + "/?" + canonicalQueryStringForTest(q)
|
||||
}
|
||||
|
||||
// canonicalQueryStringForTest mirrors the production canonical-query
|
||||
// implementation so the test signer agrees with the verifier on
|
||||
// canonicalisation. Sorting + encoding rules must stay aligned; if either
|
||||
// implementation drifts, signature verification breaks immediately —
|
||||
// caught by TestVerifyPresignedGetCallerIdentity_HappyPath.
|
||||
func canonicalQueryStringForTest(q url.Values) string {
|
||||
type kv struct{ k, v string }
|
||||
pairs := make([]kv, 0, len(q))
|
||||
for k, vs := range q {
|
||||
ek := awsQueryEscape(k)
|
||||
for _, v := range vs {
|
||||
pairs = append(pairs, kv{ek, awsQueryEscape(v)})
|
||||
}
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].k == pairs[j].k {
|
||||
return pairs[i].v < pairs[j].v
|
||||
}
|
||||
return pairs[i].k < pairs[j].k
|
||||
})
|
||||
parts := make([]string, len(pairs))
|
||||
for i, p := range pairs {
|
||||
parts[i] = p.k + "=" + p.v
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
return req.URL.String()
|
||||
}
|
||||
|
||||
// seedAccessKey writes an active IAM access key for the user and returns its
|
||||
@@ -184,40 +127,23 @@ func TestVerifyPresignedGetCallerIdentity_CrossClusterReplay(t *testing.T) {
|
||||
|
||||
func TestVerifyPresignedGetCallerIdentity_MissingXK8sAwsIdInSignedHeaders(t *testing.T) {
|
||||
// An attacker presigning without x-k8s-aws-id in SignedHeaders strips the
|
||||
// cross-cluster check from the canonical request. Verify must refuse
|
||||
// before any signature work — the URL is structurally untrustworthy.
|
||||
// cross-cluster check from the canonical request. Verify must refuse even
|
||||
// though the signature itself is valid — the URL is structurally untrustworthy.
|
||||
svc, _ := newTestSetup(t)
|
||||
akid, secret := seedAccessKey(t, svc, testCallerAccountID, "eve")
|
||||
|
||||
signedAt := time.Now().UTC().Truncate(time.Second)
|
||||
withFrozenTime(t, signedAt)
|
||||
|
||||
date := signedAt.UTC().Format("20060102")
|
||||
amzDate := signedAt.UTC().Format("20060102T150405Z")
|
||||
credentialScope := date + "/au-mel-1/sts/aws4_request"
|
||||
q := url.Values{}
|
||||
q.Set("Action", "GetCallerIdentity")
|
||||
q.Set("Version", "2011-06-15")
|
||||
q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
||||
q.Set("X-Amz-Credential", akid+"/"+credentialScope)
|
||||
q.Set("X-Amz-Date", amzDate)
|
||||
q.Set("X-Amz-Expires", "900")
|
||||
q.Set("X-Amz-SignedHeaders", "host") // no x-k8s-aws-id
|
||||
// Presign without setting X-K8s-Aws-Id, so SignedHeaders is host only.
|
||||
req, err := http.NewRequest(http.MethodGet,
|
||||
"https://"+testPresignedHost+"/?Action=GetCallerIdentity&Version=2011-06-15", nil)
|
||||
require.NoError(t, err)
|
||||
signer := v4.NewSigner(credentials.NewStaticCredentials(akid, secret, ""))
|
||||
_, err = signer.Presign(req, nil, presignedService, testPresignedRegion, 900*time.Second, signedAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
canonQ := canonicalQueryStringForTest(q)
|
||||
canonHeaders := "host:" + testPresignedHost + "\n"
|
||||
canonReq := strings.Join([]string{
|
||||
"GET", "/", canonQ, canonHeaders, "host", emptyStringSHA256,
|
||||
}, "\n")
|
||||
stringToSign := strings.Join([]string{
|
||||
"AWS4-HMAC-SHA256", amzDate, credentialScope, hexSHA256(canonReq),
|
||||
}, "\n")
|
||||
key := deriveSigningKey(secret, date, "au-mel-1", "sts")
|
||||
sig := hex.EncodeToString(hmacSHA256(key, stringToSign))
|
||||
q.Set("X-Amz-Signature", sig)
|
||||
u := "https://" + testPresignedHost + "/?" + canonicalQueryStringForTest(q)
|
||||
|
||||
_, err := svc.VerifyPresignedGetCallerIdentity(u, "any-cluster")
|
||||
_, err = svc.VerifyPresignedGetCallerIdentity(req.URL.String(), "any-cluster")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, err.Error())
|
||||
}
|
||||
@@ -353,47 +279,11 @@ func TestVerifyPresignedGetCallerIdentity_CredentialDateMismatch(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorInvalidIdentityToken, err.Error())
|
||||
}
|
||||
|
||||
// ----- Component unit tests -----------------------------------------------
|
||||
|
||||
func TestAWSQueryEscape(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"abc", "abc"},
|
||||
{"a b", "a%20b"},
|
||||
{"a+b", "a%2Bb"},
|
||||
{"a/b", "a%2Fb"},
|
||||
{"a=b", "a%3Db"},
|
||||
{"~_.-", "~_.-"}, // unreserved set passes through
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, awsQueryEscape(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSigningKey_KnownVector(t *testing.T) {
|
||||
// From AWS SigV4 docs (Example signing key derivation).
|
||||
// Inputs:
|
||||
// secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"
|
||||
// date = "20120215", region = "us-east-1", service = "iam"
|
||||
// The doc's expected signing-key hex digest stays stable across SDK versions.
|
||||
key := deriveSigningKey(
|
||||
"wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
"20120215", "us-east-1", "iam",
|
||||
)
|
||||
const wantHex = "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d"
|
||||
assert.Equal(t, wantHex, hex.EncodeToString(key))
|
||||
}
|
||||
|
||||
// TestVerifyPresignedGetCallerIdentity_RealSDKPresign signs the URL with the
|
||||
// actual aws-sdk-go v4 presigner — the same canonicalisation `aws eks
|
||||
// get-token` uses — rather than the hand-rolled presignTestURL. This is the
|
||||
// faithful end-to-end guard: it caught the payload-hash bug (verify used the
|
||||
// S3-only "UNSIGNED-PAYLOAD" instead of the empty-string SHA256 that botocore
|
||||
// signs non-S3 requests with), which every hand-rolled test masked by using
|
||||
// the same wrong constant.
|
||||
// get-token` uses. This is the faithful end-to-end guard: it caught the
|
||||
// payload-hash bug (verify used the S3-only "UNSIGNED-PAYLOAD" instead of the
|
||||
// empty-string SHA256 that botocore signs non-S3 requests with).
|
||||
func TestVerifyPresignedGetCallerIdentity_RealSDKPresign(t *testing.T) {
|
||||
svc, _ := newTestSetup(t)
|
||||
akid, secret := seedAccessKey(t, svc, testCallerAccountID, "carol")
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package lbagent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -14,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
"github.com/mulgadc/predastore/pkg/sigv4"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
@@ -75,9 +72,9 @@ func TestNew_SocketPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignedPost_ProducesVerifiableSignature confirms the migrated signing path
|
||||
// (predastore/auth.SignReq over aws-sdk-go-v2) produces a request the gateway's
|
||||
// SigV4 verifier accepts, including a body-hash that matches X-Amz-Content-Sha256.
|
||||
// TestSignedPost_ProducesVerifiableSignature confirms the agent's signing path
|
||||
// (aws-sdk-go-v2 via gwsign) produces a request the gateway's sigv4 verifier
|
||||
// accepts, including a body-hash that matches X-Amz-Content-Sha256.
|
||||
func TestSignedPost_ProducesVerifiableSignature(t *testing.T) {
|
||||
const (
|
||||
accessKey = "AKIAIOSFODNN7EXAMPLE"
|
||||
@@ -88,17 +85,15 @@ func TestSignedPost_ProducesVerifiableSignature(t *testing.T) {
|
||||
var parsed bool
|
||||
var verifyErr error
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
sum := sha256.Sum256(body)
|
||||
req, err := auth.ParseReq(r)
|
||||
// sigv4.Parse reads and hashes the body itself; don't drain r.Body first.
|
||||
sr, err := sigv4.Parse(r)
|
||||
if err != nil {
|
||||
verifyErr = err
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parsed = true
|
||||
verifyErr = req.Verify(secretKey, "elasticloadbalancing", region,
|
||||
auth.WithBodyHash(hex.EncodeToString(sum[:])))
|
||||
_, verifyErr = sr.Verify(secretKey, region, "elasticloadbalancing")
|
||||
fmt.Fprint(w, "ok")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
Reference in New Issue
Block a user