mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-17 20:57:27 +00: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.
344 lines
12 KiB
Go
344 lines
12 KiB
Go
package policy
|
|
|
|
/*
|
|
* MinIO Cloud Storage, (C) 2015, 2016, 2017 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 (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// startWithConds - map which indicates if a given condition supports starts-with policy operator
|
|
var startsWithConds = map[string]bool{
|
|
"$acl": true,
|
|
"$bucket": false,
|
|
"$cache-control": true,
|
|
"$content-type": true,
|
|
"$content-disposition": true,
|
|
"$content-encoding": true,
|
|
"$expires": true,
|
|
"$key": true,
|
|
"$success_action_redirect": true,
|
|
"$redirect": true,
|
|
"$success_action_status": false,
|
|
"$x-amz-algorithm": false,
|
|
"$x-amz-credential": false,
|
|
"$x-amz-date": false,
|
|
}
|
|
|
|
// Add policy conditionals.
|
|
const (
|
|
policyCondEqual = "eq"
|
|
policyCondStartsWith = "starts-with"
|
|
policyCondContentLength = "content-length-range"
|
|
)
|
|
|
|
// toString - Safely convert interface to string without causing panic.
|
|
func toString(val interface{}) string {
|
|
switch v := val.(type) {
|
|
case string:
|
|
return v
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// toLowerString - safely convert interface to lower string
|
|
func toLowerString(val interface{}) string {
|
|
return strings.ToLower(toString(val))
|
|
}
|
|
|
|
// toInteger _ Safely convert interface to integer without causing panic.
|
|
func toInteger(val interface{}) (int64, error) {
|
|
switch v := val.(type) {
|
|
case float64:
|
|
return int64(v), nil
|
|
case int64:
|
|
return v, nil
|
|
case int:
|
|
return int64(v), nil
|
|
case string:
|
|
i, err := strconv.Atoi(v)
|
|
return int64(i), err
|
|
default:
|
|
return 0, errors.New("Invalid number format")
|
|
}
|
|
}
|
|
|
|
// isString - Safely check if val is of type string without causing panic.
|
|
func isString(val interface{}) bool {
|
|
_, ok := val.(string)
|
|
return ok
|
|
}
|
|
|
|
// ContentLengthRange - policy content-length-range field.
|
|
type contentLengthRange struct {
|
|
Min int64
|
|
Max int64
|
|
Valid bool // If content-length-range was part of policy
|
|
}
|
|
|
|
// PostPolicyForm provides strict static type conversion and validation for Amazon S3's POST policy JSON string.
|
|
type PostPolicyForm struct {
|
|
Expiration time.Time // Expiration date and time of the POST policy.
|
|
Conditions struct { // Conditional policy structure.
|
|
Policies []struct {
|
|
Operator string
|
|
Key string
|
|
Value string
|
|
}
|
|
ContentLengthRange contentLengthRange
|
|
}
|
|
}
|
|
|
|
// ParsePostPolicyForm - Parse JSON policy string into typed PostPolicyForm structure.
|
|
func ParsePostPolicyForm(policy string) (ppf PostPolicyForm, e error) {
|
|
// Convert po into interfaces and
|
|
// perform strict type conversion using reflection.
|
|
var rawPolicy struct {
|
|
Expiration string `json:"expiration"`
|
|
Conditions []interface{} `json:"conditions"`
|
|
}
|
|
|
|
err := json.Unmarshal([]byte(policy), &rawPolicy)
|
|
if err != nil {
|
|
return ppf, err
|
|
}
|
|
|
|
parsedPolicy := PostPolicyForm{}
|
|
|
|
// Parse expiry time.
|
|
parsedPolicy.Expiration, err = time.Parse(time.RFC3339Nano, rawPolicy.Expiration)
|
|
if err != nil {
|
|
return ppf, err
|
|
}
|
|
|
|
// Parse conditions.
|
|
for _, val := range rawPolicy.Conditions {
|
|
switch condt := val.(type) {
|
|
case map[string]interface{}: // Handle key:value map types.
|
|
for k, v := range condt {
|
|
if !isString(v) { // Pre-check value type.
|
|
// All values must be of type string.
|
|
return parsedPolicy, fmt.Errorf("Unknown type %s of conditional field value %s found in POST policy form", reflect.TypeOf(condt).String(), condt)
|
|
}
|
|
// {"acl": "public-read" } is an alternate way to indicate - [ "eq", "$acl", "public-read" ]
|
|
// In this case we will just collapse this into "eq" for all use cases.
|
|
parsedPolicy.Conditions.Policies = append(parsedPolicy.Conditions.Policies, struct {
|
|
Operator string
|
|
Key string
|
|
Value string
|
|
}{
|
|
policyCondEqual, "$" + strings.ToLower(k), toString(v),
|
|
})
|
|
}
|
|
case []interface{}: // Handle array types.
|
|
if len(condt) != 3 { // Return error if we have insufficient elements.
|
|
return parsedPolicy, fmt.Errorf("Malformed conditional fields %s of type %s found in POST policy form", condt, reflect.TypeOf(condt).String())
|
|
}
|
|
switch toLowerString(condt[0]) {
|
|
case policyCondEqual, policyCondStartsWith:
|
|
for _, v := range condt { // Pre-check all values for type.
|
|
if !isString(v) {
|
|
// All values must be of type string.
|
|
return parsedPolicy, fmt.Errorf("Unknown type %s of conditional field value %s found in POST policy form", reflect.TypeOf(condt).String(), condt)
|
|
}
|
|
}
|
|
operator, matchType, value := toLowerString(condt[0]), toLowerString(condt[1]), toString(condt[2])
|
|
if !strings.HasPrefix(matchType, "$") {
|
|
return parsedPolicy, fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]", operator, matchType, value)
|
|
}
|
|
parsedPolicy.Conditions.Policies = append(parsedPolicy.Conditions.Policies, struct {
|
|
Operator string
|
|
Key string
|
|
Value string
|
|
}{
|
|
operator, matchType, value,
|
|
})
|
|
case policyCondContentLength:
|
|
min, err := toInteger(condt[1])
|
|
if err != nil {
|
|
return parsedPolicy, err
|
|
}
|
|
|
|
max, err := toInteger(condt[2])
|
|
if err != nil {
|
|
return parsedPolicy, err
|
|
}
|
|
|
|
parsedPolicy.Conditions.ContentLengthRange = contentLengthRange{
|
|
Min: min,
|
|
Max: max,
|
|
Valid: true,
|
|
}
|
|
default:
|
|
// Condition should be valid.
|
|
return parsedPolicy, fmt.Errorf("Unknown type %s of conditional field value %s found in POST policy form",
|
|
reflect.TypeOf(condt).String(), condt)
|
|
}
|
|
default:
|
|
return parsedPolicy, fmt.Errorf("Unknown field %s of type %s found in POST policy form",
|
|
condt, reflect.TypeOf(condt).String())
|
|
}
|
|
}
|
|
return parsedPolicy, nil
|
|
}
|
|
|
|
// checkPolicyCond returns a boolean to indicate if a condition is satisfied according
|
|
// to the passed operator
|
|
func checkPolicyCond(op string, input1, input2 string) bool {
|
|
switch op {
|
|
case policyCondEqual:
|
|
return input1 == input2
|
|
case policyCondStartsWith:
|
|
return strings.HasPrefix(input1, input2)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// xAmzPrefixRule captures a starts-with policy condition whose key ends in
|
|
// "-" and therefore matches a prefix of form field names, e.g.
|
|
// ["starts-with","$x-amz-meta-","pfx-"]. Any form field whose name starts
|
|
// with namePrefix must have a value starting with valuePrefix.
|
|
type xAmzPrefixRule struct {
|
|
policyKey string
|
|
namePrefix string
|
|
valuePrefix string
|
|
}
|
|
|
|
// postPolicyAuthFields enumerates the X-Amz-* form fields that clients are
|
|
// required to send with every POST Object request for signing/authentication.
|
|
// These must be accepted even when no matching policy condition is declared.
|
|
var postPolicyAuthFields = map[string]bool{
|
|
"X-Amz-Signature": true,
|
|
"X-Amz-Credential": true,
|
|
"X-Amz-Algorithm": true,
|
|
"X-Amz-Date": true,
|
|
"X-Amz-Security-Token": true,
|
|
}
|
|
|
|
// CheckPostPolicy - apply policy conditions and validate input values.
|
|
// (http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html)
|
|
func CheckPostPolicy(formValues http.Header, postPolicyForm PostPolicyForm) error {
|
|
// Check if policy document expiry date is still not reached
|
|
if !postPolicyForm.Expiration.After(time.Now().UTC()) {
|
|
return fmt.Errorf("Invalid according to Policy: Policy expired")
|
|
}
|
|
// Track $x-amz-* policy conditions in canonical form. A starts-with
|
|
// condition on a key that itself ends with "-" (e.g.
|
|
// ["starts-with","$x-amz-meta-","pfx-"]) is the AWS convention for
|
|
// allowing any form field sharing that name prefix; capture the name
|
|
// prefix together with its required value prefix so the extras check
|
|
// can both accept matching fields and enforce the value constraint.
|
|
policyXAmzKeys := make(map[string]bool)
|
|
var policyXAmzPrefixes []xAmzPrefixRule
|
|
for _, policy := range postPolicyForm.Conditions.Policies {
|
|
if !strings.HasPrefix(policy.Key, "$x-amz-") {
|
|
continue
|
|
}
|
|
formCanonicalName := http.CanonicalHeaderKey(strings.TrimPrefix(policy.Key, "$"))
|
|
if policy.Operator == policyCondStartsWith && strings.HasSuffix(policy.Key, "-") {
|
|
policyXAmzPrefixes = append(policyXAmzPrefixes, xAmzPrefixRule{
|
|
policyKey: policy.Key,
|
|
namePrefix: formCanonicalName,
|
|
valuePrefix: policy.Value,
|
|
})
|
|
continue
|
|
}
|
|
policyXAmzKeys[formCanonicalName] = true
|
|
}
|
|
// Reject any X-Amz-* form field that has no matching policy condition,
|
|
// except for the reserved auth/signing fields clients must always send.
|
|
// A field may be covered by an exact-key condition, by any number of
|
|
// prefix-stem rules, or both. Check every applicable rule: AWS requires
|
|
// all matching conditions to be satisfied, so exact-match coverage does
|
|
// not let a field skip the value-prefix enforcement of overlapping
|
|
// starts-with stems, and multiple stems on the same field must all hold.
|
|
// Prefix-stem conditions are then skipped in the main loop below because
|
|
// no single form field corresponds to the prefix itself.
|
|
for key := range formValues {
|
|
if !strings.HasPrefix(key, "X-Amz-") {
|
|
continue
|
|
}
|
|
if postPolicyAuthFields[key] {
|
|
continue
|
|
}
|
|
matched := policyXAmzKeys[key]
|
|
for _, rule := range policyXAmzPrefixes {
|
|
if !strings.HasPrefix(key, rule.namePrefix) {
|
|
continue
|
|
}
|
|
matched = true
|
|
if !strings.HasPrefix(formValues.Get(key), rule.valuePrefix) {
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]", policyCondStartsWith, rule.policyKey, rule.valuePrefix)
|
|
}
|
|
}
|
|
if !matched {
|
|
return fmt.Errorf("Invalid according to Policy: Extra input fields: %s", key)
|
|
}
|
|
}
|
|
|
|
// Flag to indicate if all policies conditions are satisfied
|
|
var condPassed bool
|
|
|
|
// Iterate over policy conditions and check them against received form fields
|
|
for _, policy := range postPolicyForm.Conditions.Policies {
|
|
// Form fields names are in canonical format, convert conditions names
|
|
// to canonical for simplification purpose, so `$key` will become `Key`
|
|
formCanonicalName := http.CanonicalHeaderKey(strings.TrimPrefix(policy.Key, "$"))
|
|
// Operator for the current policy condition
|
|
op := policy.Operator
|
|
// Prefix-stem x-amz-* conditions (key ending in "-" with starts-with)
|
|
// are validated above against every matching form field; skip them
|
|
// here so we do not fail on the non-existent literal form field whose
|
|
// name equals the prefix itself.
|
|
if strings.HasPrefix(policy.Key, "$x-amz-") && op == policyCondStartsWith && strings.HasSuffix(policy.Key, "-") {
|
|
continue
|
|
}
|
|
// If the current policy condition is known
|
|
if startsWithSupported, condFound := startsWithConds[policy.Key]; condFound {
|
|
// Check if the current condition supports starts-with operator
|
|
if op == policyCondStartsWith && !startsWithSupported {
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed")
|
|
}
|
|
// Check if current policy condition is satisfied
|
|
condPassed = checkPolicyCond(op, formValues.Get(formCanonicalName), policy.Value)
|
|
if !condPassed {
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed")
|
|
}
|
|
} else if strings.HasPrefix(policy.Key, "$x-amz-") {
|
|
// Check if policy condition is satisfied
|
|
condPassed = checkPolicyCond(op, formValues.Get(formCanonicalName), policy.Value)
|
|
if !condPassed {
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]", op, policy.Key, policy.Value)
|
|
}
|
|
} else {
|
|
// Unknown condition key: neither in startsWithConds nor a $x-amz-*
|
|
// prefixed key. AWS rejects these outright instead of silently
|
|
// treating the condition as satisfied.
|
|
return fmt.Errorf("Invalid according to Policy: Policy Condition failed: [%s, %s, %s]: unknown condition key", op, policy.Key, policy.Value)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|