mirror of
https://github.com/versity/versitygw.git
synced 2026-07-20 15:02:23 +00:00
577470214d
Validate required signed headers for both Authorization-header SigV4 requests and presigned URLs. The required signed header set is now `host` plus every incoming header with the `x-amz-` prefix. During request reconstruction, signed headers and explicitly ignored headers are copied into the generated request used for signature verification. If an incoming `x-amz-*` header is present but missing from the client-provided `SignedHeaders`, return `AccessDenied` with a `HeadersNotSigned` field. The `host` header remains part of the canonical request and signed header calculation. Previously, a client could sign a request without an S3 control header and then add that header after signing. For example, a presigned `PUT` URL could be generated with only `host` signed, then the actual request could include an unsigned `X-Amz-Tagging` or `X-Amz-Copy-Source` header. Because the verifier reconstructed the request only from `SignedHeaders`, that extra header was omitted from signature calculation and could pass authentication even though it changed the request semantics. This is now rejected with `AccessDenied`. Expose v4 helper methods for checking required and ignored headers, and update canonical header signing so ignored headers can still be included when a client explicitly lists them in `SignedHeaders`, while `Authorization` remains excluded from signature calculation.
262 lines
6.9 KiB
Go
262 lines
6.9 KiB
Go
// Copyright 2023 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package utils
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/smithy-go/logging"
|
|
"github.com/gofiber/fiber/v2"
|
|
v4 "github.com/versity/versitygw/aws/signer/v4"
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
const (
|
|
unsignedPayload string = "UNSIGNED-PAYLOAD"
|
|
|
|
algoHMAC string = "AWS4-HMAC-SHA256"
|
|
algoECDSA string = "AWS4-ECDSA-P256-SHA256"
|
|
)
|
|
|
|
// CheckPresignedSignature validates presigned request signature
|
|
func CheckPresignedSignature(ctx *fiber.Ctx, auth AuthData, secret string) error {
|
|
signedHdrs := strings.Split(auth.SignedHeaders, ";")
|
|
|
|
var contentLength int64
|
|
var err error
|
|
contentLengthStr := ctx.Get("Content-Length")
|
|
if contentLengthStr != "" {
|
|
contentLength, err = strconv.ParseInt(contentLengthStr, 10, 64)
|
|
if err != nil {
|
|
return s3err.GetAPIError(s3err.ErrInvalidRequest)
|
|
}
|
|
}
|
|
|
|
// Create a new http request instance from fasthttp request
|
|
req, err := createPresignedHttpRequestFromCtx(ctx, signedHdrs, contentLength)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
date, _ := time.Parse(iso8601Format, auth.Date)
|
|
|
|
signer := v4.NewSigner()
|
|
uri, _, signMeta, signErr := signer.PresignHTTP(ctx.Context(), aws.Credentials{
|
|
AccessKeyID: auth.Access,
|
|
SecretAccessKey: secret,
|
|
}, req, unsignedPayload, service, auth.Region, date, signedHdrs, func(options *v4.SignerOptions) {
|
|
options.DisableURIPathEscaping = true
|
|
if debuglogger.IsDebugEnabled() {
|
|
options.LogSigning = true
|
|
options.Logger = logging.NewStandardLogger(os.Stderr)
|
|
}
|
|
})
|
|
if signErr != nil {
|
|
return fmt.Errorf("presign generated http request: %w", err)
|
|
}
|
|
|
|
urlParts, err := url.Parse(uri)
|
|
if err != nil {
|
|
return fmt.Errorf("parse presigned url: %w", err)
|
|
}
|
|
|
|
signature := urlParts.Query().Get("X-Amz-Signature")
|
|
if signature != auth.Signature {
|
|
return s3err.GetSignatureDoesNotMatchErr(
|
|
auth.Access,
|
|
signMeta.StringToSign,
|
|
auth.Signature,
|
|
HexBytes(signMeta.StringToSign),
|
|
signMeta.CanonicalString,
|
|
HexBytes(signMeta.CanonicalString),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
|
|
//
|
|
// # ParsePresignedURIParts parses and validates request URL query parameters
|
|
//
|
|
// ?X-Amz-Algorithm=AWS4-HMAC-SHA256
|
|
// &X-Amz-Credential=access-key-id/20130721/us-east-1/s3/aws4_request
|
|
// &X-Amz-Date=20130721T201207Z
|
|
// &X-Amz-Expires=86400
|
|
// &X-Amz-SignedHeaders=host
|
|
// &X-Amz-Signature=1e68ad45c1db540284a4a1eca3884c293ba1a0ff63ab9db9a15b5b29dfa02cd8
|
|
func ParsePresignedURIParts(ctx *fiber.Ctx, region string) (AuthData, error) {
|
|
a := AuthData{}
|
|
|
|
// Get and verify algorithm query parameter
|
|
algo := ctx.Query("X-Amz-Algorithm")
|
|
err := validateAlgorithm(algo)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
|
|
// Parse and validate credentials query parameter
|
|
credsQuery := ctx.Query("X-Amz-Credential")
|
|
if credsQuery == "" {
|
|
return a, s3err.QueryAuthErrors.MissingRequiredParams()
|
|
}
|
|
|
|
creds, err := ParseCredentials(credsQuery, s3err.QueryAuthErrors)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
|
|
// validate the region
|
|
if creds.Region != region {
|
|
return a, s3err.QueryAuthErrors.IncorrectRegion(region, creds.Region)
|
|
}
|
|
|
|
// Parse and validate Date query param
|
|
date := ctx.Query("X-Amz-Date")
|
|
if date == "" {
|
|
return a, s3err.QueryAuthErrors.MissingRequiredParams()
|
|
}
|
|
|
|
tdate, err := time.Parse(iso8601Format, date)
|
|
if err != nil {
|
|
return a, s3err.QueryAuthErrors.InvalidXAmzDateFormat()
|
|
}
|
|
|
|
if date[:8] != creds.Date {
|
|
return a, s3err.QueryAuthErrors.DateMismatch(creds.Date, date[:8])
|
|
}
|
|
|
|
signature := ctx.Query("X-Amz-Signature")
|
|
if signature == "" {
|
|
return a, s3err.QueryAuthErrors.MissingRequiredParams()
|
|
}
|
|
|
|
signedHdrs := ctx.Query("X-Amz-SignedHeaders")
|
|
if signedHdrs == "" {
|
|
return a, s3err.QueryAuthErrors.MissingRequiredParams()
|
|
}
|
|
|
|
// Validate X-Amz-Expires query param and check if request is expired
|
|
err = validateExpiration(ctx.Query("X-Amz-Expires"), tdate)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
|
|
a.Signature = signature
|
|
a.Access = creds.Access
|
|
a.Algorithm = algo
|
|
a.Region = creds.Region
|
|
a.SignedHeaders = signedHdrs
|
|
a.Date = date
|
|
|
|
return a, nil
|
|
}
|
|
|
|
func validateExpiration(str string, date time.Time) error {
|
|
if str == "" {
|
|
return s3err.QueryAuthErrors.MissingRequiredParams()
|
|
}
|
|
|
|
exp, err := strconv.Atoi(str)
|
|
if err != nil {
|
|
return s3err.QueryAuthErrors.ExpiresNumber()
|
|
}
|
|
|
|
if exp < 0 {
|
|
return s3err.QueryAuthErrors.ExpiresNegative()
|
|
}
|
|
|
|
if exp > 604800 {
|
|
return s3err.QueryAuthErrors.ExpiresTooLarge()
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
expiresAt := date.Add(time.Duration(exp) * time.Second)
|
|
|
|
if expiresAt.Before(now) {
|
|
return s3err.GetExpiredPresignedURLError(exp, expiresAt.Format(time.RFC3339), now.Format(time.RFC3339))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateAlgorithm validates the algorithm
|
|
// for AWS4-ECDSA-P256-SHA256 it returns a custom non AWS error
|
|
// currently only AWS4-HMAC-SHA256 algorithm is supported
|
|
func validateAlgorithm(algo string) error {
|
|
switch algo {
|
|
case "":
|
|
return s3err.QueryAuthErrors.MissingRequiredParams()
|
|
case algoHMAC:
|
|
return nil
|
|
case algoECDSA:
|
|
return s3err.QueryAuthErrors.OnlyHMACSupported()
|
|
default:
|
|
// all other algorithms are considered as invalid
|
|
return s3err.QueryAuthErrors.UnsupportedAlgorithm()
|
|
}
|
|
}
|
|
|
|
// IsPresignedURLAuth determines if the request is presigned:
|
|
// which is authorization with query params
|
|
func IsPresignedURLAuth(ctx *fiber.Ctx) bool {
|
|
algo := ctx.Query("X-Amz-Algorithm")
|
|
creds := ctx.Query("X-Amz-Credential")
|
|
signature := ctx.Query("X-Amz-Signature")
|
|
signedHeaders := ctx.Query("X-Amz-SignedHeaders")
|
|
expires := ctx.Query("X-Amz-Expires")
|
|
|
|
return !allEmpty(algo, creds, signature, signedHeaders, expires) || IsPresignedURLAuthV2(ctx)
|
|
}
|
|
|
|
// IsPresignedURLAuthV2 determines if the request is
|
|
// query-string signed with aws v2 signer
|
|
func IsPresignedURLAuthV2(ctx *fiber.Ctx) bool {
|
|
expires := ctx.Query("Expires")
|
|
access := ctx.Query("AWSAccessKeyId")
|
|
signature := ctx.Query("Signature")
|
|
|
|
return anyNonEmpty(expires, access, signature)
|
|
}
|
|
|
|
// allEmpty reports whether every given string is empty.
|
|
func allEmpty(args ...string) bool {
|
|
for _, a := range args {
|
|
if a != "" {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// anyNonEmpty reports whether at least one given string is non-empty.
|
|
func anyNonEmpty(args ...string) bool {
|
|
for _, a := range args {
|
|
if a != "" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|