mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-11 00:37:52 +02:00
* fix(s3): reject unknown POST policy conditions and extra x-amz form fields CheckPostPolicy previously accepted policy conditions with unknown $keys (e.g. "$foo") as satisfied, and only rejected stray X-Amz-Meta-* form fields. Reject unknown condition keys outright, and extend the extra- input-fields check to all X-Amz-* form fields except the reserved auth/signing headers. Matches AWS S3 POST Object behavior. * refactor(s3): drop redundant $x-amz-meta- prefix check in CheckPostPolicy The $x-amz- prefix already subsumes $x-amz-meta-, so the explicit $x-amz-meta- check adds no coverage. Simplify the else-if condition. Addresses gemini-code-assist review on PR #9124. * style(s3): align unknown-key policy error with [op, key, value] trailer Reformat the unknown-condition-key error in CheckPostPolicy to include the same "[op, key, value]" trailer used by the other condition-failed messages. The value slot is empty because no comparison occurs for an unknown key. The descriptive "unknown condition key" suffix is kept so operators can still tell this failure from a mismatched value. * fix(s3): honor starts-with prefix-stem POST policies when checking extras AWS POST policies use ["starts-with","$x-amz-meta-",""] to allow any X-Amz-Meta-* form field. The previous exact-match policyXAmzKeys would flag every X-Amz-Meta-Foo as an "Extra input fields" failure because only the stem X-Amz-Meta- was stored. Track starts-with conditions whose key ends in "-" with an empty value as prefix stems, and accept any X-Amz-* form field matching one of those stems. * fix(s3): validate value prefix for starts-with POST policy stems Drop the policy.Value == "" gate when detecting prefix-stem conditions so that ["starts-with","$x-amz-meta-","pfx-"] is recognized as a prefix rule. Track the required value prefix alongside the name prefix, enforce it against every matching form field in the extras loop, and skip the prefix-stem condition in the main iteration (it has no single form field to evaluate). Also include policy.Value in the unknown-condition error trailer for clearer debugging. Addresses gemini-code-assist review on PR #9124. * fix(s3): check every matching POST policy rule, not just the first The extras loop exited early on exact-key match and broke on the first matching prefix stem. Per AWS, a form field must satisfy every policy condition that applies to it, so an exact-match field must still honor any overlapping starts-with stem's value prefix, and multiple stems on the same field must all hold. Drop both early exits: start matched from the exact-key lookup, iterate all prefix stems, and fail on the first value-prefix violation. Addresses gemini-code-assist review on PR #9124.
655 lines
24 KiB
Go
655 lines
24 KiB
Go
package policy
|
|
|
|
/*
|
|
* MinIO Cloud Storage, (C) 2016, 2017, 2018 MinIO, Inc.
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
iso8601DateFormat = "20060102T150405Z"
|
|
iso8601TimeFormat = "2006-01-02T15:04:05.000Z" // Reply date format with nanosecond precision.
|
|
)
|
|
|
|
func newPostPolicyBytesV4WithContentRange(credential, bucketName, objectKey string, expiration time.Time) []byte {
|
|
t := time.Now().UTC()
|
|
// Add the expiration date.
|
|
expirationStr := fmt.Sprintf(`"expiration": "%s"`, expiration.Format(iso8601TimeFormat))
|
|
// Add the bucket condition, only accept buckets equal to the one passed.
|
|
bucketConditionStr := fmt.Sprintf(`["eq", "$bucket", "%s"]`, bucketName)
|
|
// Add the key condition, only accept keys equal to the one passed.
|
|
keyConditionStr := fmt.Sprintf(`["eq", "$key", "%s/upload.txt"]`, objectKey)
|
|
// Add content length condition, only accept content sizes of a given length.
|
|
contentLengthCondStr := `["content-length-range", 1024, 1048576]`
|
|
// Add the algorithm condition, only accept AWS SignV4 Sha256.
|
|
algorithmConditionStr := `["eq", "$x-amz-algorithm", "AWS4-HMAC-SHA256"]`
|
|
// Add the date condition, only accept the current date.
|
|
dateConditionStr := fmt.Sprintf(`["eq", "$x-amz-date", "%s"]`, t.Format(iso8601DateFormat))
|
|
// Add the credential string, only accept the credential passed.
|
|
credentialConditionStr := fmt.Sprintf(`["eq", "$x-amz-credential", "%s"]`, credential)
|
|
// Add the meta-uuid string, set to 1234
|
|
uuidConditionStr := fmt.Sprintf(`["eq", "$x-amz-meta-uuid", "%s"]`, "1234")
|
|
|
|
// Combine all conditions into one string.
|
|
conditionStr := fmt.Sprintf(`"conditions":[%s, %s, %s, %s, %s, %s, %s]`, bucketConditionStr,
|
|
keyConditionStr, contentLengthCondStr, algorithmConditionStr, dateConditionStr, credentialConditionStr, uuidConditionStr)
|
|
retStr := "{"
|
|
retStr = retStr + expirationStr + ","
|
|
retStr = retStr + conditionStr
|
|
retStr = retStr + "}"
|
|
|
|
return []byte(retStr)
|
|
}
|
|
|
|
// newPostPolicyBytesV4 - creates a bare bones postpolicy string with key and bucket matches.
|
|
func newPostPolicyBytesV4(credential, bucketName, objectKey string, expiration time.Time) []byte {
|
|
t := time.Now().UTC()
|
|
// Add the expiration date.
|
|
expirationStr := fmt.Sprintf(`"expiration": "%s"`, expiration.Format(iso8601TimeFormat))
|
|
// Add the bucket condition, only accept buckets equal to the one passed.
|
|
bucketConditionStr := fmt.Sprintf(`["eq", "$bucket", "%s"]`, bucketName)
|
|
// Add the key condition, only accept keys equal to the one passed.
|
|
keyConditionStr := fmt.Sprintf(`["eq", "$key", "%s/upload.txt"]`, objectKey)
|
|
// Add the algorithm condition, only accept AWS SignV4 Sha256.
|
|
algorithmConditionStr := `["eq", "$x-amz-algorithm", "AWS4-HMAC-SHA256"]`
|
|
// Add the date condition, only accept the current date.
|
|
dateConditionStr := fmt.Sprintf(`["eq", "$x-amz-date", "%s"]`, t.Format(iso8601DateFormat))
|
|
// Add the credential string, only accept the credential passed.
|
|
credentialConditionStr := fmt.Sprintf(`["eq", "$x-amz-credential", "%s"]`, credential)
|
|
// Add the meta-uuid string, set to 1234
|
|
uuidConditionStr := fmt.Sprintf(`["eq", "$x-amz-meta-uuid", "%s"]`, "1234")
|
|
|
|
// Combine all conditions into one string.
|
|
conditionStr := fmt.Sprintf(`"conditions":[%s, %s, %s, %s, %s, %s]`, bucketConditionStr, keyConditionStr, algorithmConditionStr, dateConditionStr, credentialConditionStr, uuidConditionStr)
|
|
retStr := "{"
|
|
retStr = retStr + expirationStr + ","
|
|
retStr = retStr + conditionStr
|
|
retStr = retStr + "}"
|
|
|
|
return []byte(retStr)
|
|
}
|
|
|
|
// newPostPolicyBytesV2 - creates a bare bones postpolicy string with key and bucket matches.
|
|
func newPostPolicyBytesV2(bucketName, objectKey string, expiration time.Time) []byte {
|
|
// Add the expiration date.
|
|
expirationStr := fmt.Sprintf(`"expiration": "%s"`, expiration.Format(iso8601TimeFormat))
|
|
// Add the bucket condition, only accept buckets equal to the one passed.
|
|
bucketConditionStr := fmt.Sprintf(`["eq", "$bucket", "%s"]`, bucketName)
|
|
// Add the key condition, only accept keys equal to the one passed.
|
|
keyConditionStr := fmt.Sprintf(`["starts-with", "$key", "%s/upload.txt"]`, objectKey)
|
|
|
|
// Combine all conditions into one string.
|
|
conditionStr := fmt.Sprintf(`"conditions":[%s, %s]`, bucketConditionStr, keyConditionStr)
|
|
retStr := "{"
|
|
retStr = retStr + expirationStr + ","
|
|
retStr = retStr + conditionStr
|
|
retStr = retStr + "}"
|
|
|
|
return []byte(retStr)
|
|
}
|
|
|
|
// Wrapper for calling TestPostPolicyBucketHandler tests for both Erasure multiple disks and single node setup.
|
|
|
|
// testPostPolicyBucketHandler - Tests validate post policy handler uploading objects.
|
|
|
|
// Wrapper for calling TestPostPolicyBucketHandlerRedirect tests for both Erasure multiple disks and single node setup.
|
|
|
|
// testPostPolicyBucketHandlerRedirect tests POST Object when success_action_redirect is specified
|
|
|
|
// postPresignSignatureV4 - presigned signature for PostPolicy requests.
|
|
func postPresignSignatureV4(policyBase64 string, t time.Time, secretAccessKey, location string) string {
|
|
// Get signing key.
|
|
signingkey := getSigningKey(secretAccessKey, t, location)
|
|
// Calculate signature.
|
|
signature := getSignature(signingkey, policyBase64)
|
|
return signature
|
|
}
|
|
|
|
// copied from auth_signature_v4.go to break import loop
|
|
// sumHMAC calculate hmac between two input byte array.
|
|
func sumHMAC(key []byte, data []byte) []byte {
|
|
hash := hmac.New(sha256.New, key)
|
|
hash.Write(data)
|
|
return hash.Sum(nil)
|
|
}
|
|
|
|
// copied from auth_signature_v4.go to break import loop
|
|
// getSigningKey hmac seed to calculate final signature.
|
|
func getSigningKey(secretKey string, t time.Time, region string) []byte {
|
|
date := sumHMAC([]byte("AWS4"+secretKey), []byte(t.Format("20060102")))
|
|
regionBytes := sumHMAC(date, []byte(region))
|
|
service := sumHMAC(regionBytes, []byte("s3"))
|
|
signingKey := sumHMAC(service, []byte("aws4_request"))
|
|
return signingKey
|
|
}
|
|
|
|
// copied from auth_signature_v4.go to break import loop
|
|
// getSignature final signature in hexadecimal form.
|
|
func getSignature(signingKey []byte, stringToSign string) string {
|
|
return hex.EncodeToString(sumHMAC(signingKey, []byte(stringToSign)))
|
|
}
|
|
|
|
// copied from auth_signature_v4.go to break import loop
|
|
func calculateSignatureV2(stringToSign string, secret string) string {
|
|
hm := hmac.New(sha1.New, []byte(secret))
|
|
hm.Write([]byte(stringToSign))
|
|
return base64.StdEncoding.EncodeToString(hm.Sum(nil))
|
|
}
|
|
|
|
func newPostRequestV2(endPoint, bucketName, objectName string, accessKey, secretKey string) (*http.Request, error) {
|
|
// Expire the request five minutes from now.
|
|
expirationTime := time.Now().UTC().Add(time.Minute * 5)
|
|
// Create a new post policy.
|
|
policy := newPostPolicyBytesV2(bucketName, objectName, expirationTime)
|
|
// Only need the encoding.
|
|
encodedPolicy := base64.StdEncoding.EncodeToString(policy)
|
|
|
|
// Presign with V4 signature based on the policy.
|
|
signature := calculateSignatureV2(encodedPolicy, secretKey)
|
|
|
|
formData := map[string]string{
|
|
"AWSAccessKeyId": accessKey,
|
|
"bucket": bucketName,
|
|
"key": objectName + "/${filename}",
|
|
"policy": encodedPolicy,
|
|
"signature": signature,
|
|
}
|
|
|
|
// Create the multipart form.
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
|
|
// Set the normal formData
|
|
for k, v := range formData {
|
|
w.WriteField(k, v)
|
|
}
|
|
// Set the File formData
|
|
writer, err := w.CreateFormFile("file", "upload.txt")
|
|
if err != nil {
|
|
// return nil, err
|
|
return nil, err
|
|
}
|
|
writer.Write([]byte("hello world"))
|
|
// Close before creating the new request.
|
|
w.Close()
|
|
|
|
// Set the body equal to the created policy.
|
|
reader := bytes.NewReader(buf.Bytes())
|
|
|
|
req, err := http.NewRequest(http.MethodPost, makeTestTargetURL(endPoint, bucketName, "", nil), reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set form content-type.
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
return req, nil
|
|
}
|
|
|
|
func buildGenericPolicy(t time.Time, accessKey, region, bucketName, objectName string, contentLengthRange bool) []byte {
|
|
// Expire the request five minutes from now.
|
|
expirationTime := t.Add(time.Minute * 5)
|
|
|
|
credStr := getCredentialString(accessKey, region, t)
|
|
// Create a new post policy.
|
|
policy := newPostPolicyBytesV4(credStr, bucketName, objectName, expirationTime)
|
|
if contentLengthRange {
|
|
policy = newPostPolicyBytesV4WithContentRange(credStr, bucketName, objectName, expirationTime)
|
|
}
|
|
return policy
|
|
}
|
|
|
|
func newPostRequestV4Generic(endPoint, bucketName, objectName string, objData []byte, accessKey, secretKey string, region string,
|
|
t time.Time, policy []byte, addFormData map[string]string, corruptedB64 bool, corruptedMultipart bool) (*http.Request, error) {
|
|
// Get the user credential.
|
|
credStr := getCredentialString(accessKey, region, t)
|
|
|
|
// Only need the encoding.
|
|
encodedPolicy := base64.StdEncoding.EncodeToString(policy)
|
|
|
|
if corruptedB64 {
|
|
encodedPolicy = "%!~&" + encodedPolicy
|
|
}
|
|
|
|
// Presign with V4 signature based on the policy.
|
|
signature := postPresignSignatureV4(encodedPolicy, t, secretKey, region)
|
|
|
|
formData := map[string]string{
|
|
"bucket": bucketName,
|
|
"key": objectName + "/${filename}",
|
|
"x-amz-credential": credStr,
|
|
"policy": encodedPolicy,
|
|
"x-amz-signature": signature,
|
|
"x-amz-date": t.Format(iso8601DateFormat),
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
"x-amz-meta-uuid": "1234",
|
|
"Content-Encoding": "gzip",
|
|
}
|
|
|
|
// Add form data
|
|
for k, v := range addFormData {
|
|
formData[k] = v
|
|
}
|
|
|
|
// Create the multipart form.
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
|
|
// Set the normal formData
|
|
for k, v := range formData {
|
|
w.WriteField(k, v)
|
|
}
|
|
// Set the File formData but don't if we want send an incomplete multipart request
|
|
if !corruptedMultipart {
|
|
writer, err := w.CreateFormFile("file", "upload.txt")
|
|
if err != nil {
|
|
// return nil, err
|
|
return nil, err
|
|
}
|
|
writer.Write(objData)
|
|
// Close before creating the new request.
|
|
w.Close()
|
|
}
|
|
|
|
// Set the body equal to the created policy.
|
|
reader := bytes.NewReader(buf.Bytes())
|
|
|
|
req, err := http.NewRequest(http.MethodPost, makeTestTargetURL(endPoint, bucketName, "", nil), reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set form content-type.
|
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
|
return req, nil
|
|
}
|
|
|
|
func newPostRequestV4WithContentLength(endPoint, bucketName, objectName string, objData []byte, accessKey, secretKey string) (*http.Request, error) {
|
|
t := time.Now().UTC()
|
|
region := "us-east-1"
|
|
policy := buildGenericPolicy(t, accessKey, region, bucketName, objectName, true)
|
|
return newPostRequestV4Generic(endPoint, bucketName, objectName, objData, accessKey, secretKey, region, t, policy, nil, false, false)
|
|
}
|
|
|
|
func newPostRequestV4(endPoint, bucketName, objectName string, objData []byte, accessKey, secretKey string) (*http.Request, error) {
|
|
t := time.Now().UTC()
|
|
region := "us-east-1"
|
|
policy := buildGenericPolicy(t, accessKey, region, bucketName, objectName, false)
|
|
return newPostRequestV4Generic(endPoint, bucketName, objectName, objData, accessKey, secretKey, region, t, policy, nil, false, false)
|
|
}
|
|
|
|
// construct URL for http requests for bucket operations.
|
|
func makeTestTargetURL(endPoint, bucketName, objectName string, queryValues url.Values) string {
|
|
urlStr := endPoint + "/"
|
|
if bucketName != "" {
|
|
urlStr = urlStr + bucketName + "/"
|
|
}
|
|
if objectName != "" {
|
|
urlStr = urlStr + EncodePath(objectName)
|
|
}
|
|
if len(queryValues) > 0 {
|
|
urlStr = urlStr + "?" + queryValues.Encode()
|
|
}
|
|
return urlStr
|
|
}
|
|
|
|
// if object matches reserved string, no need to encode them
|
|
var reservedObjectNames = regexp.MustCompile("^[a-zA-Z0-9-_.~/]+$")
|
|
|
|
// EncodePath encode the strings from UTF-8 byte representations to HTML hex escape sequences
|
|
//
|
|
// This is necessary since regular url.Parse() and url.Encode() functions do not support UTF-8
|
|
// non english characters cannot be parsed due to the nature in which url.Encode() is written
|
|
//
|
|
// This function on the other hand is a direct replacement for url.Encode() technique to support
|
|
// pretty much every UTF-8 character.
|
|
func EncodePath(pathName string) string {
|
|
if reservedObjectNames.MatchString(pathName) {
|
|
return pathName
|
|
}
|
|
var encodedPathname string
|
|
for _, s := range pathName {
|
|
if 'A' <= s && s <= 'Z' || 'a' <= s && s <= 'z' || '0' <= s && s <= '9' { // §2.3 Unreserved characters (mark)
|
|
encodedPathname = encodedPathname + string(s)
|
|
continue
|
|
}
|
|
switch s {
|
|
case '-', '_', '.', '~', '/': // §2.3 Unreserved characters (mark)
|
|
encodedPathname = encodedPathname + string(s)
|
|
continue
|
|
default:
|
|
len := utf8.RuneLen(s)
|
|
if len < 0 {
|
|
// if utf8 cannot convert return the same string as is
|
|
return pathName
|
|
}
|
|
u := make([]byte, len)
|
|
utf8.EncodeRune(u, s)
|
|
for _, r := range u {
|
|
hex := hex.EncodeToString([]byte{r})
|
|
encodedPathname = encodedPathname + "%" + strings.ToUpper(hex)
|
|
}
|
|
}
|
|
}
|
|
return encodedPathname
|
|
}
|
|
|
|
// getCredentialString generate a credential string.
|
|
func getCredentialString(accessKeyID, location string, t time.Time) string {
|
|
return accessKeyID + "/" + getScope(t, location)
|
|
}
|
|
|
|
// getScope generate a string of a specific date, an AWS region, and a service.
|
|
func getScope(t time.Time, region string) string {
|
|
scope := strings.Join([]string{
|
|
t.Format("20060102"),
|
|
region,
|
|
string("s3"),
|
|
"aws4_request",
|
|
}, "/")
|
|
return scope
|
|
}
|
|
|
|
// buildParsedPolicy is a small test helper that assembles a JSON policy
|
|
// document with the supplied condition snippets and parses it into a
|
|
// PostPolicyForm. Using ParsePostPolicyForm avoids having to construct the
|
|
// anonymous struct in PostPolicyForm.Conditions.Policies directly.
|
|
func buildParsedPolicy(t *testing.T, conditions string) PostPolicyForm {
|
|
t.Helper()
|
|
expiration := time.Now().UTC().Add(24 * time.Hour).Format(iso8601TimeFormat)
|
|
raw := fmt.Sprintf(`{"expiration":"%s","conditions":[%s]}`, expiration, conditions)
|
|
ppf, err := ParsePostPolicyForm(raw)
|
|
if err != nil {
|
|
t.Fatalf("ParsePostPolicyForm failed: %v\npolicy: %s", err, raw)
|
|
}
|
|
return ppf
|
|
}
|
|
|
|
// TestCheckPostPolicy_RejectsUnknownConditionKey verifies that a policy
|
|
// containing a condition key that is neither in startsWithConds nor prefixed
|
|
// with $x-amz- is rejected instead of being silently accepted.
|
|
func TestCheckPostPolicy_RejectsUnknownConditionKey(t *testing.T) {
|
|
ppf := buildParsedPolicy(t, `["eq","$foo","bar"]`)
|
|
|
|
form := http.Header{}
|
|
form.Set("Foo", "bar")
|
|
|
|
err := CheckPostPolicy(form, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error for unknown condition key, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown condition key") {
|
|
t.Fatalf("expected 'unknown condition key' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "$foo") {
|
|
t.Fatalf("expected error to name the offending key, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_RejectsExtraXAmzFormField verifies that stray X-Amz-*
|
|
// form fields (beyond the reserved auth/signing ones) are rejected when no
|
|
// matching policy condition is declared.
|
|
func TestCheckPostPolicy_RejectsExtraXAmzFormField(t *testing.T) {
|
|
ppf := buildParsedPolicy(t, `["eq","$bucket","mybucket"]`)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Storage-Class", "STANDARD")
|
|
|
|
err := CheckPostPolicy(form, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error for extra X-Amz-Storage-Class field, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Extra input fields") {
|
|
t.Fatalf("expected 'Extra input fields' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "X-Amz-Storage-Class") {
|
|
t.Fatalf("expected error to name the offending field, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_AllowsXAmzAuthFields verifies that the reserved
|
|
// auth/signing X-Amz-* headers are accepted even when no policy condition
|
|
// mentions them, because clients must always send these.
|
|
func TestCheckPostPolicy_AllowsXAmzAuthFields(t *testing.T) {
|
|
ppf := buildParsedPolicy(t, `["eq","$bucket","mybucket"]`)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Signature", "deadbeef")
|
|
form.Set("X-Amz-Credential", "AKIA/20260417/us-east-1/s3/aws4_request")
|
|
form.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256")
|
|
form.Set("X-Amz-Date", "20260417T000000Z")
|
|
form.Set("X-Amz-Security-Token", "session-token")
|
|
|
|
if err := CheckPostPolicy(form, ppf); err != nil {
|
|
t.Fatalf("expected no error with only reserved auth fields, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_AllowsMatchingXAmzField verifies that an X-Amz-* form
|
|
// field is accepted when there is a matching equality policy condition.
|
|
func TestCheckPostPolicy_AllowsMatchingXAmzField(t *testing.T) {
|
|
ppf := buildParsedPolicy(t, `["eq","$bucket","mybucket"],["eq","$x-amz-storage-class","STANDARD"]`)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Storage-Class", "STANDARD")
|
|
|
|
if err := CheckPostPolicy(form, ppf); err != nil {
|
|
t.Fatalf("expected no error for matching X-Amz-Storage-Class, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_ExistingXAmzMetaCheckStillWorks is a regression test
|
|
// for the pre-existing behavior: a stray X-Amz-Meta-* form field without a
|
|
// matching condition is still rejected.
|
|
func TestCheckPostPolicy_ExistingXAmzMetaCheckStillWorks(t *testing.T) {
|
|
ppf := buildParsedPolicy(t, `["eq","$bucket","mybucket"]`)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Meta-Foo", "bar")
|
|
|
|
err := CheckPostPolicy(form, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error for extra X-Amz-Meta-Foo field, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Extra input fields") {
|
|
t.Fatalf("expected 'Extra input fields' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "X-Amz-Meta-Foo") {
|
|
t.Fatalf("expected error to name the offending field, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_AllowsStartsWithPrefixStem covers the AWS convention
|
|
// where ["starts-with","$x-amz-meta-",""] permits any X-Amz-Meta-* form
|
|
// field. Without prefix-stem handling, such fields would be wrongly
|
|
// rejected as "Extra input fields".
|
|
func TestCheckPostPolicy_AllowsStartsWithPrefixStem(t *testing.T) {
|
|
ppf := buildParsedPolicy(t,
|
|
`["eq","$bucket","mybucket"],["starts-with","$x-amz-meta-",""]`,
|
|
)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Meta-Foo", "bar")
|
|
form.Set("X-Amz-Meta-Another", "baz")
|
|
|
|
if err := CheckPostPolicy(form, ppf); err != nil {
|
|
t.Fatalf("expected no error for prefix-matched meta fields, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_PrefixStemDoesNotCoverOtherPrefixes ensures the
|
|
// prefix allowance is scoped: a starts-with stem for x-amz-meta- must not
|
|
// whitelist unrelated x-amz-* fields like x-amz-storage-class.
|
|
func TestCheckPostPolicy_PrefixStemDoesNotCoverOtherPrefixes(t *testing.T) {
|
|
ppf := buildParsedPolicy(t,
|
|
`["eq","$bucket","mybucket"],["starts-with","$x-amz-meta-",""]`,
|
|
)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Storage-Class", "STANDARD")
|
|
|
|
err := CheckPostPolicy(form, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error for X-Amz-Storage-Class not covered by meta prefix, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "X-Amz-Storage-Class") {
|
|
t.Fatalf("expected error to name X-Amz-Storage-Class, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_PrefixStemEnforcesValuePrefix covers a starts-with
|
|
// prefix-stem policy with a non-empty required value prefix: matching
|
|
// fields must have values that satisfy the value prefix.
|
|
func TestCheckPostPolicy_PrefixStemEnforcesValuePrefix(t *testing.T) {
|
|
ppf := buildParsedPolicy(t,
|
|
`["eq","$bucket","mybucket"],["starts-with","$x-amz-meta-","pfx-"]`,
|
|
)
|
|
|
|
// Value satisfies the required prefix: accepted.
|
|
okForm := http.Header{}
|
|
okForm.Set("Bucket", "mybucket")
|
|
okForm.Set("X-Amz-Meta-Foo", "pfx-bar")
|
|
if err := CheckPostPolicy(okForm, ppf); err != nil {
|
|
t.Fatalf("expected no error when meta value matches required prefix, got: %v", err)
|
|
}
|
|
|
|
// Value does not satisfy the required prefix: rejected as policy failure.
|
|
badForm := http.Header{}
|
|
badForm.Set("Bucket", "mybucket")
|
|
badForm.Set("X-Amz-Meta-Foo", "other")
|
|
err := CheckPostPolicy(badForm, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error when meta value misses required prefix, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Policy Condition failed") {
|
|
t.Fatalf("expected 'Policy Condition failed' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "pfx-") {
|
|
t.Fatalf("expected error to reference the required value prefix, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_ExactAndPrefixBothEnforced covers a field that is
|
|
// simultaneously covered by an exact-key condition and a prefix-stem
|
|
// condition. Both must be satisfied; exact-match alone does not let the
|
|
// field skip the stem's value-prefix check.
|
|
func TestCheckPostPolicy_ExactAndPrefixBothEnforced(t *testing.T) {
|
|
ppf := buildParsedPolicy(t,
|
|
`["eq","$bucket","mybucket"],`+
|
|
`["eq","$x-amz-meta-tag","gold"],`+
|
|
`["starts-with","$x-amz-meta-","lvl-"]`,
|
|
)
|
|
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
// Satisfies the exact eq condition but not the starts-with stem.
|
|
form.Set("X-Amz-Meta-Tag", "gold")
|
|
|
|
err := CheckPostPolicy(form, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error: exact-match field must still satisfy overlapping prefix stem, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Policy Condition failed") {
|
|
t.Fatalf("expected 'Policy Condition failed' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "lvl-") {
|
|
t.Fatalf("expected error to reference the stem's value prefix, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_MultiplePrefixStemsAllEnforced covers a field that
|
|
// matches multiple starts-with prefix stems. All stems must hold; a value
|
|
// that satisfies one but not the other must be rejected.
|
|
func TestCheckPostPolicy_MultiplePrefixStemsAllEnforced(t *testing.T) {
|
|
ppf := buildParsedPolicy(t,
|
|
`["eq","$bucket","mybucket"],`+
|
|
`["starts-with","$x-amz-meta-","pfx-"],`+
|
|
`["starts-with","$x-amz-meta-color-","red-"]`,
|
|
)
|
|
|
|
// Satisfies the broader stem but not the color- stem's value prefix.
|
|
form := http.Header{}
|
|
form.Set("Bucket", "mybucket")
|
|
form.Set("X-Amz-Meta-Color-Main", "pfx-green")
|
|
|
|
err := CheckPostPolicy(form, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error: field must satisfy every matching prefix stem, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Policy Condition failed") {
|
|
t.Fatalf("expected 'Policy Condition failed' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "red-") {
|
|
t.Fatalf("expected error to reference the failing value prefix, got: %v", err)
|
|
}
|
|
|
|
// A policy with compatible stems (broad allows anything, narrow
|
|
// requires "red-") accepts a value honoring both.
|
|
compatible := buildParsedPolicy(t,
|
|
`["eq","$bucket","mybucket"],`+
|
|
`["starts-with","$x-amz-meta-",""],`+
|
|
`["starts-with","$x-amz-meta-color-","red-"]`,
|
|
)
|
|
okForm := http.Header{}
|
|
okForm.Set("Bucket", "mybucket")
|
|
okForm.Set("X-Amz-Meta-Color-Main", "red-main")
|
|
if err := CheckPostPolicy(okForm, compatible); err != nil {
|
|
t.Fatalf("expected no error when value satisfies both compatible stems, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCheckPostPolicy_UnknownKeyErrorIncludesPolicyValue ensures the
|
|
// unknown-condition error surfaces the policy value in its [op, key, value]
|
|
// trailer so operators can tell which of several unknown keys failed.
|
|
func TestCheckPostPolicy_UnknownKeyErrorIncludesPolicyValue(t *testing.T) {
|
|
ppf := buildParsedPolicy(t, `["eq","$foo","custom-value"]`)
|
|
|
|
err := CheckPostPolicy(http.Header{}, ppf)
|
|
if err == nil {
|
|
t.Fatalf("expected error for unknown condition key, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "custom-value") {
|
|
t.Fatalf("expected error to include policy.Value 'custom-value', got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "$foo") {
|
|
t.Fatalf("expected error to include policy.Key '$foo', got: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown condition key") {
|
|
t.Fatalf("expected 'unknown condition key' suffix, got: %v", err)
|
|
}
|
|
}
|