mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2025-11-09 05:54:46 +08:00
s3: combine all signature verification checks into a single function (#7330)
This commit is contained in:
@@ -25,7 +25,6 @@ import (
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -33,17 +32,20 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
)
|
||||
|
||||
func (iam *IdentityAccessManagement) reqSignatureV4Verify(r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
sha256sum := getContentSha256Cksum(r)
|
||||
switch {
|
||||
case isRequestSignatureV4(r):
|
||||
return iam.doesSignatureMatch(sha256sum, r)
|
||||
identity, _, errCode := iam.doesSignatureMatch(r)
|
||||
return identity, errCode
|
||||
case isRequestPresignedSignatureV4(r):
|
||||
return iam.doesPresignedSignatureMatch(sha256sum, r)
|
||||
identity, _, errCode := iam.doesPresignedSignatureMatch(r)
|
||||
return identity, errCode
|
||||
}
|
||||
return nil, s3err.ErrAccessDenied
|
||||
}
|
||||
@@ -154,236 +156,298 @@ func parseSignV4(v4Auth string) (sv signValues, aec s3err.ErrorCode) {
|
||||
return signV4Values, s3err.ErrNone
|
||||
}
|
||||
|
||||
// doesSignatureMatch verifies the request signature.
|
||||
func (iam *IdentityAccessManagement) doesSignatureMatch(hashedPayload string, r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
// buildPathWithForwardedPrefix combines forwarded prefix with URL path while preserving S3 key semantics.
|
||||
// This function avoids path.Clean which would collapse "//" and dot segments, breaking S3 signatures.
|
||||
// It only normalizes the join boundary to avoid double slashes between prefix and path.
|
||||
func buildPathWithForwardedPrefix(forwardedPrefix, urlPath string) string {
|
||||
if forwardedPrefix == "" {
|
||||
return urlPath
|
||||
}
|
||||
// Ensure single leading slash on prefix
|
||||
if !strings.HasPrefix(forwardedPrefix, "/") {
|
||||
forwardedPrefix = "/" + forwardedPrefix
|
||||
}
|
||||
// Join without collapsing interior segments; only fix a double slash at the boundary
|
||||
var joined string
|
||||
if strings.HasSuffix(forwardedPrefix, "/") && strings.HasPrefix(urlPath, "/") {
|
||||
joined = forwardedPrefix + urlPath[1:]
|
||||
} else if !strings.HasSuffix(forwardedPrefix, "/") && !strings.HasPrefix(urlPath, "/") {
|
||||
joined = forwardedPrefix + "/" + urlPath
|
||||
} else {
|
||||
joined = forwardedPrefix + urlPath
|
||||
}
|
||||
// Trailing slash semantics inherited from urlPath (already present if needed)
|
||||
return joined
|
||||
}
|
||||
|
||||
// Copy request
|
||||
req := *r
|
||||
// v4AuthInfo holds the parsed authentication data from a request,
|
||||
// whether it's from the Authorization header or presigned URL query parameters.
|
||||
type v4AuthInfo struct {
|
||||
Signature string
|
||||
AccessKey string
|
||||
SignedHeaders []string
|
||||
Date time.Time
|
||||
Region string
|
||||
Service string
|
||||
Scope string
|
||||
HashedPayload string
|
||||
IsPresigned bool
|
||||
}
|
||||
|
||||
// Save authorization header.
|
||||
v4Auth := req.Header.Get("Authorization")
|
||||
// verifyV4Signature is the single entry point for verifying any AWS Signature V4 request.
|
||||
// It handles standard requests, presigned URLs, and the seed signature for streaming uploads.
|
||||
func (iam *IdentityAccessManagement) verifyV4Signature(r *http.Request, shouldCheckPermissions bool) (identity *Identity, credential *Credential, calculatedSignature string, authInfo *v4AuthInfo, errCode s3err.ErrorCode) {
|
||||
// 1. Extract authentication information from header or query parameters
|
||||
authInfo, errCode = extractV4AuthInfo(r)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, nil, "", nil, errCode
|
||||
}
|
||||
|
||||
// Parse signature version '4' header.
|
||||
signV4Values, errCode := parseSignV4(v4Auth)
|
||||
// 2. Lookup user and credentials
|
||||
identity, cred, found := iam.lookupByAccessKey(authInfo.AccessKey)
|
||||
if !found {
|
||||
return nil, nil, "", nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// 3. Perform permission check
|
||||
if shouldCheckPermissions {
|
||||
bucket, object := s3_constants.GetBucketAndObject(r)
|
||||
action := s3_constants.ACTION_READ
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
action = s3_constants.ACTION_WRITE
|
||||
}
|
||||
if !identity.canDo(Action(action), bucket, object) {
|
||||
return nil, nil, "", nil, s3err.ErrAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle presigned request expiration
|
||||
if authInfo.IsPresigned {
|
||||
if errCode = checkPresignedRequestExpiry(r, authInfo.Date); errCode != s3err.ErrNone {
|
||||
return nil, nil, "", nil, errCode
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Extract headers that were part of the signature
|
||||
extractedSignedHeaders, errCode := extractSignedHeaders(authInfo.SignedHeaders, r)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, nil, "", nil, errCode
|
||||
}
|
||||
|
||||
// 6. Get the query string for the canonical request
|
||||
queryStr := getCanonicalQueryString(r, authInfo.IsPresigned)
|
||||
|
||||
// 7. Define a closure for the core verification logic to avoid repetition
|
||||
verify := func(urlPath string) (string, s3err.ErrorCode) {
|
||||
return calculateAndVerifySignature(
|
||||
cred.SecretKey,
|
||||
r.Method,
|
||||
urlPath,
|
||||
queryStr,
|
||||
extractedSignedHeaders,
|
||||
authInfo,
|
||||
)
|
||||
}
|
||||
|
||||
// 8. Verify the signature, trying with X-Forwarded-Prefix first
|
||||
if forwardedPrefix := r.Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" {
|
||||
cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, r.URL.Path)
|
||||
calculatedSignature, errCode = verify(cleanedPath)
|
||||
if errCode == s3err.ErrNone {
|
||||
return identity, cred, calculatedSignature, authInfo, s3err.ErrNone
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Verify with the original path
|
||||
calculatedSignature, errCode = verify(r.URL.Path)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, nil, "", nil, errCode
|
||||
}
|
||||
|
||||
return identity, cred, calculatedSignature, authInfo, s3err.ErrNone
|
||||
}
|
||||
|
||||
// calculateAndVerifySignature contains the core logic for creating the canonical request,
|
||||
// string-to-sign, and comparing the final signature.
|
||||
func calculateAndVerifySignature(secretKey, method, urlPath, queryStr string, extractedSignedHeaders http.Header, authInfo *v4AuthInfo) (string, s3err.ErrorCode) {
|
||||
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, authInfo.HashedPayload, queryStr, urlPath, method)
|
||||
stringToSign := getStringToSign(canonicalRequest, authInfo.Date, authInfo.Scope)
|
||||
signingKey := getSigningKey(secretKey, authInfo.Date.Format(yyyymmdd), authInfo.Region, authInfo.Service)
|
||||
newSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
if !compareSignatureV4(newSignature, authInfo.Signature) {
|
||||
glog.V(4).Infof("Signature mismatch. Details:\n- CanonicalRequest: %q\n- StringToSign: %q\n- Calculated: %s, Provided: %s",
|
||||
canonicalRequest, stringToSign, newSignature, authInfo.Signature)
|
||||
return "", s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
|
||||
return newSignature, s3err.ErrNone
|
||||
}
|
||||
|
||||
func extractV4AuthInfo(r *http.Request) (*v4AuthInfo, s3err.ErrorCode) {
|
||||
if isRequestPresignedSignatureV4(r) {
|
||||
return extractV4AuthInfoFromQuery(r)
|
||||
}
|
||||
return extractV4AuthInfoFromHeader(r)
|
||||
}
|
||||
|
||||
func extractV4AuthInfoFromHeader(r *http.Request) (*v4AuthInfo, s3err.ErrorCode) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
signV4Values, errCode := parseSignV4(authHeader)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
|
||||
// Compute payload hash for non-S3 services
|
||||
if signV4Values.Credential.scope.service != "s3" && hashedPayload == emptySHA256 && r.Body != nil {
|
||||
var err error
|
||||
hashedPayload, err = streamHashRequestBody(r, iamRequestBodyLimit)
|
||||
var t time.Time
|
||||
if xamz := r.Header.Get("x-amz-date"); xamz != "" {
|
||||
parsed, err := time.Parse(iso8601Format, xamz)
|
||||
if err != nil {
|
||||
return nil, s3err.ErrMalformedDate
|
||||
}
|
||||
t = parsed
|
||||
} else {
|
||||
ds := r.Header.Get("Date")
|
||||
if ds == "" {
|
||||
return nil, s3err.ErrMissingDateHeader
|
||||
}
|
||||
parsed, err := http.ParseTime(ds)
|
||||
if err != nil {
|
||||
return nil, s3err.ErrMalformedDate
|
||||
}
|
||||
t = parsed.UTC()
|
||||
}
|
||||
|
||||
// Validate clock skew: requests cannot be older than 15 minutes from server time to prevent replay attacks
|
||||
const maxSkew = 15 * time.Minute
|
||||
now := time.Now().UTC()
|
||||
if now.Sub(t) > maxSkew || t.Sub(now) > maxSkew {
|
||||
return nil, s3err.ErrRequestTimeTooSkewed
|
||||
}
|
||||
|
||||
hashedPayload := getContentSha256Cksum(r)
|
||||
if signV4Values.Credential.scope.service != "s3" && hashedPayload == emptySHA256 && r.Body != nil {
|
||||
var hashErr error
|
||||
hashedPayload, hashErr = streamHashRequestBody(r, iamRequestBodyLimit)
|
||||
if hashErr != nil {
|
||||
return nil, s3err.ErrInternalError
|
||||
}
|
||||
}
|
||||
|
||||
// Extract all the signed headers along with its values.
|
||||
extractedSignedHeaders, errCode := extractSignedHeaders(signV4Values.SignedHeaders, r)
|
||||
return &v4AuthInfo{
|
||||
Signature: signV4Values.Signature,
|
||||
AccessKey: signV4Values.Credential.accessKey,
|
||||
SignedHeaders: signV4Values.SignedHeaders,
|
||||
Date: t,
|
||||
Region: signV4Values.Credential.scope.region,
|
||||
Service: signV4Values.Credential.scope.service,
|
||||
Scope: signV4Values.Credential.getScope(),
|
||||
HashedPayload: hashedPayload,
|
||||
IsPresigned: false,
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
func extractV4AuthInfoFromQuery(r *http.Request) (*v4AuthInfo, s3err.ErrorCode) {
|
||||
query := r.URL.Query()
|
||||
|
||||
// Validate all required query parameters upfront for fail-fast behavior
|
||||
if query.Get("X-Amz-Algorithm") != signV4Algorithm {
|
||||
return nil, s3err.ErrSignatureVersionNotSupported
|
||||
}
|
||||
if query.Get("X-Amz-Date") == "" {
|
||||
return nil, s3err.ErrMissingDateHeader
|
||||
}
|
||||
if query.Get("X-Amz-Credential") == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
if query.Get("X-Amz-Signature") == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
if query.Get("X-Amz-SignedHeaders") == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
if query.Get("X-Amz-Expires") == "" {
|
||||
return nil, s3err.ErrInvalidQueryParams
|
||||
}
|
||||
|
||||
// Parse date
|
||||
dateStr := query.Get("X-Amz-Date")
|
||||
t, err := time.Parse(iso8601Format, dateStr)
|
||||
if err != nil {
|
||||
return nil, s3err.ErrMalformedDate
|
||||
}
|
||||
|
||||
// Parse credential header
|
||||
credHeader, errCode := parseCredentialHeader("Credential=" + query.Get("X-Amz-Credential"))
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
|
||||
cred := signV4Values.Credential
|
||||
identity, foundCred, found := iam.lookupByAccessKey(cred.accessKey)
|
||||
if !found {
|
||||
return nil, s3err.ErrInvalidAccessKeyID
|
||||
// For presigned URLs, X-Amz-Content-Sha256 must come from the query parameter
|
||||
// (or default to UNSIGNED-PAYLOAD) because that's what was used for signing.
|
||||
// We must NOT check the request header as it wasn't part of the signature calculation.
|
||||
hashedPayload := query.Get("X-Amz-Content-Sha256")
|
||||
if hashedPayload == "" {
|
||||
hashedPayload = unsignedPayload
|
||||
}
|
||||
|
||||
// Extract date, if not present throw error.
|
||||
var dateStr string
|
||||
if dateStr = req.Header.Get("x-amz-date"); dateStr == "" {
|
||||
if dateStr = r.Header.Get("Date"); dateStr == "" {
|
||||
return nil, s3err.ErrMissingDateHeader
|
||||
}
|
||||
}
|
||||
// Parse date header.
|
||||
t, e := time.Parse(iso8601Format, dateStr)
|
||||
if e != nil {
|
||||
return nil, s3err.ErrMalformedDate
|
||||
}
|
||||
|
||||
// Query string.
|
||||
queryStr := req.URL.Query().Encode()
|
||||
|
||||
// Check if reverse proxy is forwarding with prefix
|
||||
if forwardedPrefix := r.Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" {
|
||||
// Try signature verification with the forwarded prefix first.
|
||||
// This handles cases where reverse proxies strip URL prefixes and add the X-Forwarded-Prefix header.
|
||||
cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, req.URL.Path)
|
||||
errCode = iam.verifySignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, cleanedPath, req.Method, foundCred.SecretKey, t, signV4Values)
|
||||
if errCode == s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
}
|
||||
|
||||
// Try normal signature verification (without prefix)
|
||||
errCode = iam.verifySignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, req.URL.Path, req.Method, foundCred.SecretKey, t, signV4Values)
|
||||
if errCode == s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
|
||||
return nil, errCode
|
||||
return &v4AuthInfo{
|
||||
Signature: query.Get("X-Amz-Signature"),
|
||||
AccessKey: credHeader.accessKey,
|
||||
SignedHeaders: strings.Split(query.Get("X-Amz-SignedHeaders"), ";"),
|
||||
Date: t,
|
||||
Region: credHeader.scope.region,
|
||||
Service: credHeader.scope.service,
|
||||
Scope: credHeader.getScope(),
|
||||
HashedPayload: hashedPayload,
|
||||
IsPresigned: true,
|
||||
}, s3err.ErrNone
|
||||
}
|
||||
|
||||
// buildPathWithForwardedPrefix combines forwarded prefix with URL path while preserving trailing slashes.
|
||||
// This ensures compatibility with S3 SDK signatures that include trailing slashes for directory operations.
|
||||
func buildPathWithForwardedPrefix(forwardedPrefix, urlPath string) string {
|
||||
fullPath := forwardedPrefix + urlPath
|
||||
hasTrailingSlash := strings.HasSuffix(urlPath, "/") && urlPath != "/"
|
||||
cleanedPath := path.Clean(fullPath)
|
||||
if hasTrailingSlash && !strings.HasSuffix(cleanedPath, "/") {
|
||||
cleanedPath += "/"
|
||||
func getCanonicalQueryString(r *http.Request, isPresigned bool) string {
|
||||
var queryToEncode string
|
||||
if !isPresigned {
|
||||
queryToEncode = r.URL.Query().Encode()
|
||||
} else {
|
||||
queryForCanonical := r.URL.Query()
|
||||
queryForCanonical.Del("X-Amz-Signature")
|
||||
queryToEncode = queryForCanonical.Encode()
|
||||
}
|
||||
return cleanedPath
|
||||
return queryToEncode
|
||||
}
|
||||
|
||||
// verifySignatureWithPath verifies signature with a given path (used for both normal and prefixed paths).
|
||||
func (iam *IdentityAccessManagement) verifySignatureWithPath(extractedSignedHeaders http.Header, hashedPayload, queryStr, urlPath, method, secretKey string, t time.Time, signV4Values signValues) s3err.ErrorCode {
|
||||
// Get canonical request.
|
||||
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, urlPath, method)
|
||||
|
||||
// Get string to sign from canonical request.
|
||||
stringToSign := getStringToSign(canonicalRequest, t, signV4Values.Credential.getScope())
|
||||
|
||||
// Get hmac signing key.
|
||||
signingKey := getSigningKey(secretKey, signV4Values.Credential.scope.date.Format(yyyymmdd), signV4Values.Credential.scope.region, signV4Values.Credential.scope.service)
|
||||
|
||||
// Calculate signature.
|
||||
newSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Verify if signature match.
|
||||
if !compareSignatureV4(newSignature, signV4Values.Signature) {
|
||||
return s3err.ErrSignatureDoesNotMatch
|
||||
func checkPresignedRequestExpiry(r *http.Request, t time.Time) s3err.ErrorCode {
|
||||
expiresStr := r.URL.Query().Get("X-Amz-Expires")
|
||||
// X-Amz-Expires is validated as required in extractV4AuthInfoFromQuery,
|
||||
// so it should never be empty here
|
||||
expires, err := strconv.ParseInt(expiresStr, 10, 64)
|
||||
if err != nil {
|
||||
return s3err.ErrMalformedDate
|
||||
}
|
||||
|
||||
// The maximum value for X-Amz-Expires is 604800 seconds (7 days)
|
||||
// Allow 0 but it will immediately fail expiration check
|
||||
if expires < 0 {
|
||||
return s3err.ErrNegativeExpires
|
||||
}
|
||||
if expires > 604800 {
|
||||
return s3err.ErrMaximumExpires
|
||||
}
|
||||
|
||||
expirationTime := t.Add(time.Duration(expires) * time.Second)
|
||||
if time.Now().UTC().After(expirationTime) {
|
||||
return s3err.ErrExpiredPresignRequest
|
||||
}
|
||||
return s3err.ErrNone
|
||||
}
|
||||
|
||||
// verifyPresignedSignatureWithPath verifies presigned signature with a given path (used for both normal and prefixed paths).
|
||||
func (iam *IdentityAccessManagement) verifyPresignedSignatureWithPath(extractedSignedHeaders http.Header, hashedPayload, queryStr, urlPath, method, secretKey string, t time.Time, credHeader credentialHeader, signature string) s3err.ErrorCode {
|
||||
// Get canonical request.
|
||||
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, urlPath, method)
|
||||
|
||||
// Get string to sign from canonical request.
|
||||
stringToSign := getStringToSign(canonicalRequest, t, credHeader.getScope())
|
||||
|
||||
// Get hmac signing key.
|
||||
signingKey := getSigningKey(secretKey, credHeader.scope.date.Format(yyyymmdd), credHeader.scope.region, credHeader.scope.service)
|
||||
|
||||
// Calculate expected signature.
|
||||
expectedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Verify if signature match.
|
||||
if !compareSignatureV4(expectedSignature, signature) {
|
||||
return s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
|
||||
return s3err.ErrNone
|
||||
func (iam *IdentityAccessManagement) doesSignatureMatch(r *http.Request) (*Identity, string, s3err.ErrorCode) {
|
||||
identity, _, calculatedSignature, _, errCode := iam.verifyV4Signature(r, false)
|
||||
return identity, calculatedSignature, errCode
|
||||
}
|
||||
|
||||
// Simple implementation for presigned signature verification
|
||||
func (iam *IdentityAccessManagement) doesPresignedSignatureMatch(hashedPayload string, r *http.Request) (*Identity, s3err.ErrorCode) {
|
||||
// Parse presigned signature values from query parameters
|
||||
query := r.URL.Query()
|
||||
|
||||
// Check required parameters
|
||||
algorithm := query.Get("X-Amz-Algorithm")
|
||||
if algorithm != signV4Algorithm {
|
||||
return nil, s3err.ErrSignatureVersionNotSupported
|
||||
}
|
||||
|
||||
credential := query.Get("X-Amz-Credential")
|
||||
if credential == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
signature := query.Get("X-Amz-Signature")
|
||||
if signature == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
signedHeadersStr := query.Get("X-Amz-SignedHeaders")
|
||||
if signedHeadersStr == "" {
|
||||
return nil, s3err.ErrMissingFields
|
||||
}
|
||||
|
||||
dateStr := query.Get("X-Amz-Date")
|
||||
if dateStr == "" {
|
||||
return nil, s3err.ErrMissingDateHeader
|
||||
}
|
||||
|
||||
// Parse credential
|
||||
credHeader, err := parseCredentialHeader("Credential=" + credential)
|
||||
if err != s3err.ErrNone {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Look up identity by access key
|
||||
identity, foundCred, found := iam.lookupByAccessKey(credHeader.accessKey)
|
||||
if !found {
|
||||
return nil, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
// Parse date
|
||||
t, e := time.Parse(iso8601Format, dateStr)
|
||||
if e != nil {
|
||||
return nil, s3err.ErrMalformedDate
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
expiresStr := query.Get("X-Amz-Expires")
|
||||
if expiresStr != "" {
|
||||
expires, parseErr := strconv.ParseInt(expiresStr, 10, 64)
|
||||
if parseErr != nil {
|
||||
return nil, s3err.ErrMalformedDate
|
||||
}
|
||||
// Check if current time is after the expiration time
|
||||
expirationTime := t.Add(time.Duration(expires) * time.Second)
|
||||
if time.Now().UTC().After(expirationTime) {
|
||||
return nil, s3err.ErrExpiredPresignRequest
|
||||
}
|
||||
}
|
||||
|
||||
// Parse signed headers
|
||||
signedHeaders := strings.Split(signedHeadersStr, ";")
|
||||
|
||||
// Extract signed headers from request
|
||||
extractedSignedHeaders := make(http.Header)
|
||||
for _, header := range signedHeaders {
|
||||
if header == "host" {
|
||||
extractedSignedHeaders[header] = []string{extractHostHeader(r)}
|
||||
continue
|
||||
}
|
||||
if values := r.Header[http.CanonicalHeaderKey(header)]; len(values) > 0 {
|
||||
extractedSignedHeaders[http.CanonicalHeaderKey(header)] = values
|
||||
}
|
||||
}
|
||||
|
||||
// Remove signature from query for canonical request calculation
|
||||
queryForCanonical := r.URL.Query()
|
||||
queryForCanonical.Del("X-Amz-Signature")
|
||||
queryStr := strings.Replace(queryForCanonical.Encode(), "+", "%20", -1)
|
||||
|
||||
var errCode s3err.ErrorCode
|
||||
// Check if reverse proxy is forwarding with prefix for presigned URLs
|
||||
if forwardedPrefix := r.Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" {
|
||||
// Try signature verification with the forwarded prefix first.
|
||||
// This handles cases where reverse proxies strip URL prefixes and add the X-Forwarded-Prefix header.
|
||||
cleanedPath := buildPathWithForwardedPrefix(forwardedPrefix, r.URL.Path)
|
||||
errCode = iam.verifyPresignedSignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, cleanedPath, r.Method, foundCred.SecretKey, t, credHeader, signature)
|
||||
if errCode == s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
}
|
||||
|
||||
// Try normal signature verification (without prefix)
|
||||
errCode = iam.verifyPresignedSignatureWithPath(extractedSignedHeaders, hashedPayload, queryStr, r.URL.Path, r.Method, foundCred.SecretKey, t, credHeader, signature)
|
||||
if errCode == s3err.ErrNone {
|
||||
return identity, errCode
|
||||
}
|
||||
|
||||
return nil, errCode
|
||||
func (iam *IdentityAccessManagement) doesPresignedSignatureMatch(r *http.Request) (*Identity, string, s3err.ErrorCode) {
|
||||
identity, _, calculatedSignature, _, errCode := iam.verifyV4Signature(r, false)
|
||||
return identity, calculatedSignature, errCode
|
||||
}
|
||||
|
||||
// credentialHeader data type represents structured form of Credential
|
||||
@@ -531,7 +595,7 @@ func extractHostHeader(r *http.Request) string {
|
||||
// Check if reverse proxy also forwarded the port
|
||||
if forwardedPort := r.Header.Get("X-Forwarded-Port"); forwardedPort != "" {
|
||||
// Determine the protocol to check for standard ports
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
proto := strings.ToLower(r.Header.Get("X-Forwarded-Proto"))
|
||||
// Only add port if it's not the standard port for the protocol
|
||||
if (proto == "https" && forwardedPort != "443") || (proto != "https" && forwardedPort != "80") {
|
||||
return forwardedHost + ":" + forwardedPort
|
||||
|
||||
91
weed/s3api/auth_signature_v4_test.go
Normal file
91
weed/s3api/auth_signature_v4_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildPathWithForwardedPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
forwardedPrefix string
|
||||
urlPath string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty prefix returns urlPath",
|
||||
forwardedPrefix: "",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix without trailing slash",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix with trailing slash",
|
||||
forwardedPrefix: "/storage/",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix without leading slash",
|
||||
forwardedPrefix: "storage",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "prefix without leading slash and with trailing slash",
|
||||
forwardedPrefix: "storage/",
|
||||
urlPath: "/bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
{
|
||||
name: "preserve double slashes in key",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/bucket//obj",
|
||||
expected: "/storage/bucket//obj",
|
||||
},
|
||||
{
|
||||
name: "preserve trailing slash in urlPath",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/bucket/folder/",
|
||||
expected: "/storage/bucket/folder/",
|
||||
},
|
||||
{
|
||||
name: "preserve trailing slash with prefix having trailing slash",
|
||||
forwardedPrefix: "/storage/",
|
||||
urlPath: "/bucket/folder/",
|
||||
expected: "/storage/bucket/folder/",
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "/",
|
||||
expected: "/storage/",
|
||||
},
|
||||
{
|
||||
name: "complex key with multiple slashes",
|
||||
forwardedPrefix: "/api/v1",
|
||||
urlPath: "/bucket/path//with///slashes",
|
||||
expected: "/api/v1/bucket/path//with///slashes",
|
||||
},
|
||||
{
|
||||
name: "urlPath without leading slash",
|
||||
forwardedPrefix: "/storage",
|
||||
urlPath: "bucket/obj",
|
||||
expected: "/storage/bucket/obj",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildPathWithForwardedPrefix(tt.forwardedPrefix, tt.urlPath)
|
||||
if result != tt.expected {
|
||||
t.Errorf("buildPathWithForwardedPrefix(%q, %q) = %q, want %q",
|
||||
tt.forwardedPrefix, tt.urlPath, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -229,8 +229,12 @@ func preSignV4(iam *IdentityAccessManagement, req *http.Request, accessKey, secr
|
||||
// Set the query on the URL (without signature yet)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
// Get the payload hash
|
||||
hashedPayload := getContentSha256Cksum(req)
|
||||
// For presigned URLs, the payload hash must be UNSIGNED-PAYLOAD (or from query param if explicitly set)
|
||||
// We should NOT use request headers as they're not part of the presigned URL
|
||||
hashedPayload := query.Get("X-Amz-Content-Sha256")
|
||||
if hashedPayload == "" {
|
||||
hashedPayload = unsignedPayload
|
||||
}
|
||||
|
||||
// Extract signed headers
|
||||
extractedSignedHeaders := make(http.Header)
|
||||
@@ -314,7 +318,7 @@ func TestSignatureV4WithForwardedPrefix(t *testing.T) {
|
||||
signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", tt.expectedPath)
|
||||
|
||||
// Test signature verification
|
||||
_, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
|
||||
_, _, errCode := iam.doesSignatureMatch(r)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Errorf("Expected successful signature validation with X-Forwarded-Prefix %q, got error: %v (code: %d)", tt.forwardedPrefix, errCode, int(errCode))
|
||||
}
|
||||
@@ -380,7 +384,7 @@ func TestSignatureV4WithForwardedPrefixTrailingSlash(t *testing.T) {
|
||||
signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", tt.expectedPath)
|
||||
|
||||
// Test signature verification - this should succeed even with trailing slashes
|
||||
_, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
|
||||
_, _, errCode := iam.doesSignatureMatch(r)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Errorf("Expected successful signature validation with trailing slash in path %q, got error: %v (code: %d)", tt.urlPath, errCode, int(errCode))
|
||||
}
|
||||
@@ -475,7 +479,7 @@ func TestSignatureV4WithForwardedPort(t *testing.T) {
|
||||
signV4WithPath(r, "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", r.URL.Path)
|
||||
|
||||
// Test signature verification
|
||||
_, errCode := iam.doesSignatureMatch(getContentSha256Cksum(r), r)
|
||||
_, _, errCode := iam.doesSignatureMatch(r)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Errorf("Expected successful signature validation with forwarded port, got error: %v (code: %d)", errCode, int(errCode))
|
||||
}
|
||||
@@ -508,12 +512,50 @@ func TestPresignedSignatureV4Basic(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test presigned signature verification
|
||||
_, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
|
||||
_, _, errCode := iam.doesPresignedSignatureMatch(r)
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Errorf("Expected successful presigned signature validation, got error: %v (code: %d)", errCode, int(errCode))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPresignedSignatureV4MissingExpires verifies that X-Amz-Expires is required for presigned URLs
|
||||
func TestPresignedSignatureV4MissingExpires(t *testing.T) {
|
||||
iam := newTestIAM()
|
||||
|
||||
// Create a presigned request
|
||||
r, err := newTestRequest("GET", "https://example.com/test-bucket/test-object", 0, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test request: %v", err)
|
||||
}
|
||||
|
||||
r = mux.SetURLVars(r, map[string]string{
|
||||
"bucket": "test-bucket",
|
||||
"object": "test-object",
|
||||
})
|
||||
r.Header.Set("Host", "example.com")
|
||||
|
||||
// Manually construct presigned URL query parameters WITHOUT X-Amz-Expires
|
||||
now := time.Now().UTC()
|
||||
dateStr := now.Format(iso8601Format)
|
||||
scope := fmt.Sprintf("%s/%s/%s/%s", now.Format(yyyymmdd), "us-east-1", "s3", "aws4_request")
|
||||
credential := fmt.Sprintf("%s/%s", "AKIAIOSFODNN7EXAMPLE", scope)
|
||||
|
||||
query := r.URL.Query()
|
||||
query.Set("X-Amz-Algorithm", signV4Algorithm)
|
||||
query.Set("X-Amz-Credential", credential)
|
||||
query.Set("X-Amz-Date", dateStr)
|
||||
// Intentionally NOT setting X-Amz-Expires
|
||||
query.Set("X-Amz-SignedHeaders", "host")
|
||||
query.Set("X-Amz-Signature", "dummy-signature") // Signature doesn't matter, should fail earlier
|
||||
r.URL.RawQuery = query.Encode()
|
||||
|
||||
// Test presigned signature verification - should fail with ErrInvalidQueryParams
|
||||
_, _, errCode := iam.doesPresignedSignatureMatch(r)
|
||||
if errCode != s3err.ErrInvalidQueryParams {
|
||||
t.Errorf("Expected ErrInvalidQueryParams for missing X-Amz-Expires, got: %v (code: %d)", errCode, int(errCode))
|
||||
}
|
||||
}
|
||||
|
||||
// Test X-Forwarded-Prefix support for presigned URLs
|
||||
func TestPresignedSignatureV4WithForwardedPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -573,7 +615,8 @@ func TestPresignedSignatureV4WithForwardedPrefix(t *testing.T) {
|
||||
r.Header.Set("X-Forwarded-Host", "example.com")
|
||||
|
||||
// Test presigned signature verification
|
||||
_, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
|
||||
_, _, errCode := iam.doesPresignedSignatureMatch(r)
|
||||
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Errorf("Expected successful presigned signature validation with X-Forwarded-Prefix %q, got error: %v (code: %d)", tt.forwardedPrefix, errCode, int(errCode))
|
||||
}
|
||||
@@ -640,7 +683,8 @@ func TestPresignedSignatureV4WithForwardedPrefixTrailingSlash(t *testing.T) {
|
||||
r.Header.Set("X-Forwarded-Host", "example.com")
|
||||
|
||||
// Test presigned signature verification - this should succeed with trailing slashes
|
||||
_, errCode := iam.doesPresignedSignatureMatch(getContentSha256Cksum(r), r)
|
||||
_, _, errCode := iam.doesPresignedSignatureMatch(r)
|
||||
|
||||
if errCode != s3err.ErrNone {
|
||||
t.Errorf("Expected successful presigned signature validation with trailing slash in path %q, got error: %v (code: %d)", tt.strippedPath, errCode, int(errCode))
|
||||
}
|
||||
@@ -669,8 +713,12 @@ func preSignV4WithPath(iam *IdentityAccessManagement, req *http.Request, accessK
|
||||
// Set the query on the URL (without signature yet)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
// Get the payload hash
|
||||
hashedPayload := getContentSha256Cksum(req)
|
||||
// For presigned URLs, the payload hash must be UNSIGNED-PAYLOAD (or from query param if explicitly set)
|
||||
// We should NOT use request headers as they're not part of the presigned URL
|
||||
hashedPayload := query.Get("X-Amz-Content-Sha256")
|
||||
if hashedPayload == "" {
|
||||
hashedPayload = unsignedPayload
|
||||
}
|
||||
|
||||
// Extract signed headers
|
||||
extractedSignedHeaders := make(http.Header)
|
||||
@@ -884,7 +932,7 @@ func signRequestV4(req *http.Request, accessKey, secretKey string) error {
|
||||
return fmt.Errorf("Invalid hashed payload")
|
||||
}
|
||||
|
||||
currTime := time.Now()
|
||||
currTime := time.Now().UTC()
|
||||
|
||||
// Set x-amz-date.
|
||||
req.Header.Set("x-amz-date", currTime.Format(iso8601Format))
|
||||
@@ -1061,10 +1109,6 @@ func TestIAMPayloadHashComputation(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
req.Header.Set("Host", "localhost:8111")
|
||||
|
||||
// Compute expected payload hash
|
||||
expectedHash := sha256.Sum256([]byte(testPayload))
|
||||
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
||||
|
||||
// Create an IAM-style authorization header with "iam" service instead of "s3"
|
||||
now := time.Now().UTC()
|
||||
dateStr := now.Format("20060102T150405Z")
|
||||
@@ -1079,7 +1123,7 @@ func TestIAMPayloadHashComputation(t *testing.T) {
|
||||
|
||||
// Test the doesSignatureMatch function directly
|
||||
// This should now compute the correct payload hash for IAM requests
|
||||
identity, errCode := iam.doesSignatureMatch(expectedHashStr, req)
|
||||
identity, _, errCode := iam.doesSignatureMatch(req)
|
||||
|
||||
// Even though the signature will fail (dummy signature),
|
||||
// the fact that we get past the credential parsing means the payload hash was computed correctly
|
||||
@@ -1141,7 +1185,7 @@ func TestS3PayloadHashNoRegression(t *testing.T) {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// This should use the emptySHA256 hash and not try to read the body
|
||||
identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
|
||||
identity, _, errCode := iam.doesSignatureMatch(req)
|
||||
|
||||
// Should get signature mismatch (because of dummy signature) but not other errors
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
@@ -1192,7 +1236,7 @@ func TestIAMEmptyBodyPayloadHash(t *testing.T) {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// Even with an IAM request, empty body should result in emptySHA256
|
||||
identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
|
||||
identity, _, errCode := iam.doesSignatureMatch(req)
|
||||
|
||||
// Should get signature mismatch (because of dummy signature) but not other errors
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
@@ -1235,10 +1279,6 @@ func TestSTSPayloadHashComputation(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
req.Header.Set("Host", "localhost:8112")
|
||||
|
||||
// Compute expected payload hash
|
||||
expectedHash := sha256.Sum256([]byte(testPayload))
|
||||
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
||||
|
||||
// Create an STS-style authorization header with "sts" service
|
||||
now := time.Now().UTC()
|
||||
dateStr := now.Format("20060102T150405Z")
|
||||
@@ -1252,7 +1292,7 @@ func TestSTSPayloadHashComputation(t *testing.T) {
|
||||
|
||||
// Test the doesSignatureMatch function
|
||||
// This should compute the correct payload hash for STS requests (non-S3 service)
|
||||
identity, errCode := iam.doesSignatureMatch(expectedHashStr, req)
|
||||
identity, _, errCode := iam.doesSignatureMatch(req)
|
||||
|
||||
// Should get signature mismatch (dummy signature) but payload hash should be computed correctly
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
@@ -1317,7 +1357,7 @@ func TestGitHubIssue7080Scenario(t *testing.T) {
|
||||
|
||||
// Since we're using a dummy signature, we expect signature mismatch, but the important
|
||||
// thing is that it doesn't fail earlier due to payload hash computation issues
|
||||
identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
|
||||
identity, _, errCode := iam.doesSignatureMatch(req)
|
||||
|
||||
// The error should be signature mismatch, not payload related
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
@@ -1357,32 +1397,37 @@ func TestIAMSignatureServiceMatching(t *testing.T) {
|
||||
// Use the exact payload and headers from the failing logs
|
||||
testPayload := "Action=CreateAccessKey&UserName=admin&Version=2010-05-08"
|
||||
|
||||
// Use current time to avoid clock skew validation failures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Create request exactly as shown in logs
|
||||
req, err := http.NewRequest("POST", "http://localhost:8111/", strings.NewReader(testPayload))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
req.Header.Set("Host", "localhost:8111")
|
||||
req.Header.Set("X-Amz-Date", "20250805T082934Z")
|
||||
req.Header.Set("X-Amz-Date", amzDate)
|
||||
|
||||
// Calculate the expected signature using the correct IAM service
|
||||
// This simulates what botocore/AWS SDK would calculate
|
||||
credentialScope := "20250805/us-east-1/iam/aws4_request"
|
||||
credentialScope := dateStamp + "/us-east-1/iam/aws4_request"
|
||||
|
||||
// Calculate the actual payload hash for our test payload
|
||||
actualPayloadHash := getSHA256Hash([]byte(testPayload))
|
||||
|
||||
// Build the canonical request with the actual payload hash
|
||||
canonicalRequest := "POST\n/\n\ncontent-type:application/x-www-form-urlencoded; charset=utf-8\nhost:localhost:8111\nx-amz-date:20250805T082934Z\n\ncontent-type;host;x-amz-date\n" + actualPayloadHash
|
||||
canonicalRequest := "POST\n/\n\ncontent-type:application/x-www-form-urlencoded; charset=utf-8\nhost:localhost:8111\nx-amz-date:" + amzDate + "\n\ncontent-type;host;x-amz-date\n" + actualPayloadHash
|
||||
|
||||
// Calculate the canonical request hash
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
|
||||
// Build the string to sign
|
||||
stringToSign := "AWS4-HMAC-SHA256\n20250805T082934Z\n" + credentialScope + "\n" + canonicalRequestHash
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + canonicalRequestHash
|
||||
|
||||
// Calculate expected signature using IAM service (what client sends)
|
||||
expectedSigningKey := getSigningKey("power_user_secret", "20250805", "us-east-1", "iam")
|
||||
expectedSigningKey := getSigningKey("power_user_secret", dateStamp, "us-east-1", "iam")
|
||||
expectedSignature := getSignature(expectedSigningKey, stringToSign)
|
||||
|
||||
// Create authorization header with the correct signature
|
||||
@@ -1391,7 +1436,8 @@ func TestIAMSignatureServiceMatching(t *testing.T) {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// Now test that SeaweedFS computes the same signature with our fix
|
||||
identity, errCode := iam.doesSignatureMatch(actualPayloadHash, req)
|
||||
identity, computedSignature, errCode := iam.doesSignatureMatch(req)
|
||||
assert.Equal(t, expectedSignature, computedSignature)
|
||||
|
||||
// With the fix, the signatures should match and we should get a successful authentication
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
@@ -1481,7 +1527,7 @@ func TestIAMLargeBodySecurityLimit(t *testing.T) {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// The function should complete successfully but limit the body to 10 MiB
|
||||
identity, errCode := iam.doesSignatureMatch(emptySHA256, req)
|
||||
identity, _, errCode := iam.doesSignatureMatch(req)
|
||||
|
||||
// Should get signature mismatch (dummy signature) but not internal error
|
||||
assert.Equal(t, s3err.ErrSignatureDoesNotMatch, errCode)
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
@@ -47,23 +46,13 @@ import (
|
||||
// returns signature, error otherwise if the signature mismatches or any other
|
||||
// error while parsing and validating.
|
||||
func (iam *IdentityAccessManagement) calculateSeedSignature(r *http.Request) (cred *Credential, signature string, region string, service string, date time.Time, errCode s3err.ErrorCode) {
|
||||
|
||||
// Copy request.
|
||||
req := *r
|
||||
|
||||
// Save authorization header.
|
||||
v4Auth := req.Header.Get("Authorization")
|
||||
|
||||
// Parse signature version '4' header.
|
||||
signV4Values, errCode := parseSignV4(v4Auth)
|
||||
_, credential, calculatedSignature, authInfo, errCode := iam.verifyV4Signature(r, true)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, "", "", "", time.Time{}, errCode
|
||||
}
|
||||
|
||||
contentSha256Header := req.Header.Get("X-Amz-Content-Sha256")
|
||||
|
||||
switch contentSha256Header {
|
||||
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD'
|
||||
// This check ensures we only proceed for streaming uploads.
|
||||
switch authInfo.HashedPayload {
|
||||
case streamingContentSHA256:
|
||||
glog.V(3).Infof("streaming content sha256")
|
||||
case streamingUnsignedPayload:
|
||||
@@ -72,64 +61,7 @@ func (iam *IdentityAccessManagement) calculateSeedSignature(r *http.Request) (cr
|
||||
return nil, "", "", "", time.Time{}, s3err.ErrContentSHA256Mismatch
|
||||
}
|
||||
|
||||
// Payload streaming.
|
||||
payload := contentSha256Header
|
||||
|
||||
// Extract all the signed headers along with its values.
|
||||
extractedSignedHeaders, errCode := extractSignedHeaders(signV4Values.SignedHeaders, r)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, "", "", "", time.Time{}, errCode
|
||||
}
|
||||
// Verify if the access key id matches.
|
||||
identity, cred, found := iam.lookupByAccessKey(signV4Values.Credential.accessKey)
|
||||
if !found {
|
||||
return nil, "", "", "", time.Time{}, s3err.ErrInvalidAccessKeyID
|
||||
}
|
||||
|
||||
bucket, object := s3_constants.GetBucketAndObject(r)
|
||||
if !identity.canDo(s3_constants.ACTION_WRITE, bucket, object) {
|
||||
errCode = s3err.ErrAccessDenied
|
||||
return
|
||||
}
|
||||
|
||||
// Verify if region is valid.
|
||||
region = signV4Values.Credential.scope.region
|
||||
|
||||
// Extract date, if not present throw error.
|
||||
var dateStr string
|
||||
if dateStr = req.Header.Get(http.CanonicalHeaderKey("x-amz-date")); dateStr == "" {
|
||||
if dateStr = r.Header.Get("Date"); dateStr == "" {
|
||||
return nil, "", "", "", time.Time{}, s3err.ErrMissingDateHeader
|
||||
}
|
||||
}
|
||||
|
||||
// Parse date header.
|
||||
date, err := time.Parse(iso8601Format, dateStr)
|
||||
if err != nil {
|
||||
return nil, "", "", "", time.Time{}, s3err.ErrMalformedDate
|
||||
}
|
||||
// Query string.
|
||||
queryStr := req.URL.Query().Encode()
|
||||
|
||||
// Get canonical request.
|
||||
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, payload, queryStr, req.URL.Path, req.Method)
|
||||
|
||||
// Get string to sign from canonical request.
|
||||
stringToSign := getStringToSign(canonicalRequest, date, signV4Values.Credential.getScope())
|
||||
|
||||
// Get hmac signing key.
|
||||
signingKey := getSigningKey(cred.SecretKey, signV4Values.Credential.scope.date.Format(yyyymmdd), region, signV4Values.Credential.scope.service)
|
||||
|
||||
// Calculate signature.
|
||||
newSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Verify if signature match.
|
||||
if !compareSignatureV4(newSignature, signV4Values.Signature) {
|
||||
return nil, "", "", "", time.Time{}, s3err.ErrSignatureDoesNotMatch
|
||||
}
|
||||
|
||||
// Return calculated signature.
|
||||
return cred, newSignature, region, signV4Values.Credential.scope.service, date, s3err.ErrNone
|
||||
return credential, calculatedSignature, authInfo.Region, authInfo.Service, authInfo.Date, s3err.ErrNone
|
||||
}
|
||||
|
||||
const maxLineLength = 4 * humanize.KiByte // assumed <= bufio.defaultBufSize 4KiB
|
||||
@@ -149,7 +81,7 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
|
||||
contentSha256Header := req.Header.Get("X-Amz-Content-Sha256")
|
||||
authorizationHeader := req.Header.Get("Authorization")
|
||||
|
||||
var ident *Credential
|
||||
var credential *Credential
|
||||
var seedSignature, region, service string
|
||||
var seedDate time.Time
|
||||
var errCode s3err.ErrorCode
|
||||
@@ -158,7 +90,7 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
|
||||
// Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD'
|
||||
case streamingContentSHA256:
|
||||
glog.V(3).Infof("streaming content sha256")
|
||||
ident, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
|
||||
credential, seedSignature, region, service, seedDate, errCode = iam.calculateSeedSignature(req)
|
||||
if errCode != s3err.ErrNone {
|
||||
return nil, errCode
|
||||
}
|
||||
@@ -186,7 +118,7 @@ func (iam *IdentityAccessManagement) newChunkedReader(req *http.Request) (io.Rea
|
||||
checkSumWriter := getCheckSumWriter(checksumAlgorithm)
|
||||
|
||||
return &s3ChunkedReader{
|
||||
cred: ident,
|
||||
cred: credential,
|
||||
reader: bufio.NewReader(req.Body),
|
||||
seedSignature: seedSignature,
|
||||
seedDate: seedDate,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hash/crc32"
|
||||
|
||||
@@ -16,66 +17,19 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// getDefaultTimestamp returns a current timestamp for tests
|
||||
func getDefaultTimestamp() string {
|
||||
return time.Now().UTC().Format(iso8601Format)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultTimestamp = "20130524T000000Z"
|
||||
defaultTimestamp = "20130524T000000Z" // Legacy constant for reference
|
||||
defaultBucketName = "examplebucket"
|
||||
defaultAccessKeyId = "AKIAIOSFODNN7EXAMPLE"
|
||||
defaultSecretAccessKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
defaultRegion = "us-east-1"
|
||||
)
|
||||
|
||||
func generatestreamingAws4HmacSha256Payload() string {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html#example-signature-calculations-streaming
|
||||
|
||||
chunk1 := "10000;chunk-signature=ad80c730a21e5b8d04586a2213dd63b9a0e99e0e2307b0ade35a65485a288648\r\n" +
|
||||
strings.Repeat("a", 65536) + "\r\n"
|
||||
chunk2 := "400;chunk-signature=0055627c9e194cb4542bae2aa5492e3c1575bbb81b612b7d234b86a503ef5497\r\n" +
|
||||
strings.Repeat("a", 1024) + "\r\n"
|
||||
chunk3 := "0;chunk-signature=b6c6ea8a5354eaf15b3cb7646744f4275b71ea724fed81ceb9323e279d449df9\r\n" +
|
||||
"\r\n" // The last chunk is empty
|
||||
|
||||
payload := chunk1 + chunk2 + chunk3
|
||||
return payload
|
||||
}
|
||||
|
||||
func NewRequeststreamingAws4HmacSha256Payload() (*http.Request, error) {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html#example-signature-calculations-streaming
|
||||
|
||||
payload := generatestreamingAws4HmacSha256Payload()
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/examplebucket/chunkObject.txt", bytes.NewReader([]byte(payload)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", defaultTimestamp)
|
||||
req.Header.Set("x-amz-storage-class", "REDUCED_REDUNDANCY")
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=content-encoding;content-length;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-storage-class,Signature=4f232c4386841ef735655705268965c44a0e4690baa4adea153f7db9fa80a0a9")
|
||||
req.Header.Set("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD")
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "66560")
|
||||
req.Header.Set("Content-Length", "66824")
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func TestNewSignV4ChunkedReaderstreamingAws4HmacSha256Payload(t *testing.T) {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html#example-signature-calculations-streaming
|
||||
req, err := NewRequeststreamingAws4HmacSha256Payload()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create request: %v", err)
|
||||
}
|
||||
iam := setupIam()
|
||||
|
||||
// The expected payload a long string of 'a's
|
||||
expectedPayload := strings.Repeat("a", 66560)
|
||||
|
||||
runWithRequest(iam, req, t, expectedPayload)
|
||||
}
|
||||
|
||||
func generateStreamingUnsignedPayloadTrailerPayload(includeFinalCRLF bool) string {
|
||||
// This test will implement the following scenario:
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html
|
||||
@@ -117,7 +71,7 @@ func NewRequestStreamingUnsignedPayloadTrailer(includeFinalCRLF bool) (*http.Req
|
||||
}
|
||||
|
||||
req.Header.Set("Host", "amzn-s3-demo-bucket")
|
||||
req.Header.Set("x-amz-date", defaultTimestamp)
|
||||
req.Header.Set("x-amz-date", getDefaultTimestamp())
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "17408")
|
||||
req.Header.Set("x-amz-content-sha256", "STREAMING-UNSIGNED-PAYLOAD-TRAILER")
|
||||
@@ -194,3 +148,169 @@ func setupIam() IdentityAccessManagement {
|
||||
iam.accessKeyIdent[defaultAccessKeyId] = iam.identities[0]
|
||||
return iam
|
||||
}
|
||||
|
||||
// TestSignedStreamingUpload tests streaming uploads with signed chunks
|
||||
// This replaces the removed AWS example test with a dynamic signature generation approach
|
||||
func TestSignedStreamingUpload(t *testing.T) {
|
||||
iam := setupIam()
|
||||
|
||||
// Create a simple streaming upload with 2 chunks
|
||||
chunk1Data := strings.Repeat("a", 1024)
|
||||
chunk2Data := strings.Repeat("b", 512)
|
||||
|
||||
// Use current time for signatures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Calculate seed signature
|
||||
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
|
||||
|
||||
// Build canonical request for seed signature
|
||||
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
canonicalHeaders := "content-encoding:aws-chunked\n" +
|
||||
"host:s3.amazonaws.com\n" +
|
||||
"x-amz-content-sha256:" + hashedPayload + "\n" +
|
||||
"x-amz-date:" + amzDate + "\n" +
|
||||
"x-amz-decoded-content-length:1536\n"
|
||||
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length"
|
||||
|
||||
canonicalRequest := "PUT\n" +
|
||||
"/test-bucket/test-object\n" +
|
||||
"\n" +
|
||||
canonicalHeaders + "\n" +
|
||||
signedHeaders + "\n" +
|
||||
hashedPayload
|
||||
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
|
||||
|
||||
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
|
||||
seedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Calculate chunk signatures
|
||||
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
|
||||
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
|
||||
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
|
||||
|
||||
chunk2Hash := getSHA256Hash([]byte(chunk2Data))
|
||||
chunk2StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk1Signature + "\n" + emptySHA256 + "\n" + chunk2Hash
|
||||
chunk2Signature := getSignature(signingKey, chunk2StringToSign)
|
||||
|
||||
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk2Signature + "\n" + emptySHA256 + "\n" + emptySHA256
|
||||
finalSignature := getSignature(signingKey, finalStringToSign)
|
||||
|
||||
// Build the chunked payload
|
||||
payload := fmt.Sprintf("400;chunk-signature=%s\r\n%s\r\n", chunk1Signature, chunk1Data) +
|
||||
fmt.Sprintf("200;chunk-signature=%s\r\n%s\r\n", chunk2Signature, chunk2Data) +
|
||||
fmt.Sprintf("0;chunk-signature=%s\r\n\r\n", finalSignature)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
|
||||
bytes.NewReader([]byte(payload)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", hashedPayload)
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "1536")
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
defaultAccessKeyId, scope, signedHeaders, seedSignature)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// Test the chunked reader
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Read and verify the payload
|
||||
data, err := io.ReadAll(reader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, chunk1Data+chunk2Data, string(data))
|
||||
}
|
||||
|
||||
// TestSignedStreamingUploadInvalidSignature tests that invalid chunk signatures are rejected
|
||||
// This is a negative test case to ensure signature validation is actually working
|
||||
func TestSignedStreamingUploadInvalidSignature(t *testing.T) {
|
||||
iam := setupIam()
|
||||
|
||||
// Create a simple streaming upload with 1 chunk
|
||||
chunk1Data := strings.Repeat("a", 1024)
|
||||
|
||||
// Use current time for signatures
|
||||
now := time.Now().UTC()
|
||||
amzDate := now.Format(iso8601Format)
|
||||
dateStamp := now.Format(yyyymmdd)
|
||||
|
||||
// Calculate seed signature
|
||||
scope := dateStamp + "/" + defaultRegion + "/s3/aws4_request"
|
||||
|
||||
// Build canonical request for seed signature
|
||||
hashedPayload := "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
canonicalHeaders := "content-encoding:aws-chunked\n" +
|
||||
"host:s3.amazonaws.com\n" +
|
||||
"x-amz-content-sha256:" + hashedPayload + "\n" +
|
||||
"x-amz-date:" + amzDate + "\n" +
|
||||
"x-amz-decoded-content-length:1024\n"
|
||||
signedHeaders := "content-encoding;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length"
|
||||
|
||||
canonicalRequest := "PUT\n" +
|
||||
"/test-bucket/test-object\n" +
|
||||
"\n" +
|
||||
canonicalHeaders + "\n" +
|
||||
signedHeaders + "\n" +
|
||||
hashedPayload
|
||||
|
||||
canonicalRequestHash := getSHA256Hash([]byte(canonicalRequest))
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope + "\n" + canonicalRequestHash
|
||||
|
||||
signingKey := getSigningKey(defaultSecretAccessKey, dateStamp, defaultRegion, "s3")
|
||||
seedSignature := getSignature(signingKey, stringToSign)
|
||||
|
||||
// Calculate chunk signature (correct)
|
||||
chunk1Hash := getSHA256Hash([]byte(chunk1Data))
|
||||
chunk1StringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
seedSignature + "\n" + emptySHA256 + "\n" + chunk1Hash
|
||||
chunk1Signature := getSignature(signingKey, chunk1StringToSign)
|
||||
|
||||
// Calculate final signature (correct)
|
||||
finalStringToSign := "AWS4-HMAC-SHA256-PAYLOAD\n" + amzDate + "\n" + scope + "\n" +
|
||||
chunk1Signature + "\n" + emptySHA256 + "\n" + emptySHA256
|
||||
finalSignature := getSignature(signingKey, finalStringToSign)
|
||||
|
||||
// Build the chunked payload with INTENTIONALLY WRONG chunk signature
|
||||
// We'll use a modified signature to simulate a tampered request
|
||||
wrongChunkSignature := strings.Replace(chunk1Signature, "a", "b", 1)
|
||||
payload := fmt.Sprintf("400;chunk-signature=%s\r\n%s\r\n", wrongChunkSignature, chunk1Data) +
|
||||
fmt.Sprintf("0;chunk-signature=%s\r\n\r\n", finalSignature)
|
||||
|
||||
// Create the request
|
||||
req, err := http.NewRequest("PUT", "http://s3.amazonaws.com/test-bucket/test-object",
|
||||
bytes.NewReader([]byte(payload)))
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.Header.Set("Host", "s3.amazonaws.com")
|
||||
req.Header.Set("x-amz-date", amzDate)
|
||||
req.Header.Set("x-amz-content-sha256", hashedPayload)
|
||||
req.Header.Set("Content-Encoding", "aws-chunked")
|
||||
req.Header.Set("x-amz-decoded-content-length", "1024")
|
||||
|
||||
authHeader := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
defaultAccessKeyId, scope, signedHeaders, seedSignature)
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
|
||||
// Test the chunked reader - it should be created successfully
|
||||
reader, errCode := iam.newChunkedReader(req)
|
||||
assert.Equal(t, s3err.ErrNone, errCode)
|
||||
assert.NotNil(t, reader)
|
||||
|
||||
// Try to read the payload - this should fail with signature validation error
|
||||
_, err = io.ReadAll(reader)
|
||||
assert.Error(t, err, "Expected error when reading chunk with invalid signature")
|
||||
assert.Contains(t, err.Error(), "chunk signature does not match", "Error should indicate chunk signature mismatch")
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ const (
|
||||
ErrContentSHA256Mismatch
|
||||
ErrInvalidAccessKeyID
|
||||
ErrRequestNotReadyYet
|
||||
ErrRequestTimeTooSkewed
|
||||
ErrMissingDateHeader
|
||||
ErrInvalidRequest
|
||||
ErrAuthNotSetup
|
||||
@@ -432,6 +433,12 @@ var errorCodeResponse = map[ErrorCode]APIError{
|
||||
HTTPStatusCode: http.StatusForbidden,
|
||||
},
|
||||
|
||||
ErrRequestTimeTooSkewed: {
|
||||
Code: "RequestTimeTooSkewed",
|
||||
Description: "The difference between the request time and the server's time is too large.",
|
||||
HTTPStatusCode: http.StatusForbidden,
|
||||
},
|
||||
|
||||
ErrSignatureDoesNotMatch: {
|
||||
Code: "SignatureDoesNotMatch",
|
||||
Description: "The request signature we calculated does not match the signature you provided. Check your key and signing method.",
|
||||
|
||||
Reference in New Issue
Block a user