mirror of
https://github.com/versity/versitygw.git
synced 2026-04-12 17:06:58 +00:00
556 lines
16 KiB
Go
556 lines
16 KiB
Go
// Copyright 2026 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 auth
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/debuglogger"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
const (
|
|
policyFieldExpiration = "expiration"
|
|
policyFieldConditions = "conditions"
|
|
)
|
|
|
|
// POSTPolicy is the parsed browser-based upload policy document.
|
|
type POSTPolicy struct {
|
|
expiration time.Time
|
|
conditions []postPolicyCondition
|
|
}
|
|
|
|
// PostPolicyEvalInput is all the data required to evaluate the policy.
|
|
// Fields should contain already-expanded form values.
|
|
type PostPolicyEvalInput struct {
|
|
Bucket string
|
|
Key string
|
|
ContentLength int64
|
|
Fields map[string]string
|
|
}
|
|
|
|
// postPolicyCondition is the internal contract shared by all supported
|
|
// POST policy condition forms.
|
|
type postPolicyCondition interface {
|
|
validate() error
|
|
match(PostPolicyEvalInput) error
|
|
coveredField() string
|
|
isBucketCondition() bool
|
|
}
|
|
|
|
// ParsePOSTPolicyBase64 decodes and validates a base64-encoded POST policy.
|
|
func ParsePOSTPolicyBase64(encoded string) (*POSTPolicy, error) {
|
|
raw, err := decodeBase64Policy(encoded)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rp map[string]json.RawMessage
|
|
err = json.Unmarshal(raw, &rp)
|
|
if err != nil {
|
|
debuglogger.Logf("invalid POST policy JSON: %v", err)
|
|
return nil, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
|
|
if _, ok := rp[policyFieldExpiration]; !ok {
|
|
debuglogger.Logf("POST policy is missing expiration")
|
|
return nil, s3err.InvalidPolicyDocument.MissingExpiration()
|
|
}
|
|
|
|
if _, ok := rp[policyFieldConditions]; !ok {
|
|
debuglogger.Logf("POST policy is missing conditions")
|
|
return nil, s3err.InvalidPolicyDocument.MissingConditions()
|
|
}
|
|
|
|
var conditions []json.RawMessage
|
|
var expiration string
|
|
|
|
for key, value := range rp {
|
|
switch key {
|
|
case policyFieldConditions:
|
|
if err := json.Unmarshal(value, &conditions); err != nil {
|
|
debuglogger.Logf("POST policy invalid conditions: %s", value)
|
|
return nil, s3err.InvalidPolicyDocument.InvalidConditions()
|
|
}
|
|
case policyFieldExpiration:
|
|
if err := json.Unmarshal(value, &expiration); err != nil {
|
|
debuglogger.Logf("POST policy invalid expiration: %s", value)
|
|
return nil, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
default:
|
|
debuglogger.Logf("POST policy document unexpected field: %s", key)
|
|
return nil, s3err.InvalidPolicyDocument.UnexpectedField(key)
|
|
}
|
|
}
|
|
|
|
exp, err := parseExpiration(expiration)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conds := make([]postPolicyCondition, 0, len(conditions))
|
|
for _, rawCond := range conditions {
|
|
parsed, err := parseCondition(rawCond)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conds = append(conds, parsed)
|
|
}
|
|
|
|
p := &POSTPolicy{
|
|
expiration: exp,
|
|
conditions: conds,
|
|
}
|
|
|
|
err = p.validate()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// validate checks the parsed policy for structural correctness.
|
|
func (p *POSTPolicy) validate() error {
|
|
now := time.Now().UTC()
|
|
if p.expiration.Before(now) {
|
|
debuglogger.Logf("POST policy expired at %s", p.expiration.Format(time.RFC3339))
|
|
return s3err.InvalidPolicyDocument.PolicyExpired()
|
|
}
|
|
|
|
for _, cond := range p.conditions {
|
|
if err := cond.validate(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Evaluate returns nil if the input satisfies the policy.
|
|
// Otherwise it returns a deny reason.
|
|
func (p *POSTPolicy) Evaluate(in PostPolicyEvalInput) error {
|
|
// Every submitted form field must be present in conditions, except:
|
|
// x-amz-signature, file, policy, and x-ignore-*.
|
|
for field := range in.Fields {
|
|
if isIgnoredCoverageField(field) {
|
|
continue
|
|
}
|
|
if !p.hasConditionForField(field) {
|
|
debuglogger.Logf("POST policy does not cover input field: %s", field)
|
|
return s3err.InvalidPolicyDocument.ExtraInputField(field)
|
|
}
|
|
}
|
|
|
|
var metBucketCondition bool
|
|
for _, cond := range p.conditions {
|
|
if err := cond.match(in); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !metBucketCondition && cond.isBucketCondition() {
|
|
metBucketCondition = true
|
|
}
|
|
}
|
|
|
|
if !metBucketCondition {
|
|
// the bucket condition is mandatory for all policies
|
|
// it can be provided either with 'eq' or 'starts-with' operators
|
|
debuglogger.Logf("POST policy is missing the mandatory 'bucket' condition")
|
|
return s3err.InvalidPolicyDocument.ExtraInputField("bucket")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// exactCondition represents either an object-form condition or an "eq" array
|
|
// condition that requires a field to match a single value exactly.
|
|
type exactCondition struct {
|
|
field string
|
|
value string
|
|
rawCondition []byte
|
|
}
|
|
|
|
// isBucketCondition checks if the condition field is 'bucket'
|
|
func (c exactCondition) isBucketCondition() bool {
|
|
return c.field == "bucket"
|
|
}
|
|
|
|
// condition returns the policy expression used in condition failure messages.
|
|
func (c exactCondition) condition() string {
|
|
if len(c.rawCondition) == 0 {
|
|
// the key/value condition case
|
|
return fmt.Sprintf(`["eq", "$%s", "%s"]`, c.field, c.value)
|
|
}
|
|
|
|
return string(c.rawCondition)
|
|
}
|
|
|
|
// validate ensures the exact-match condition references a field.
|
|
func (c exactCondition) validate() error {
|
|
if strings.TrimSpace(c.field) == "" {
|
|
debuglogger.Logf("empty field in POST policy 'eq' condition")
|
|
return s3err.InvalidPolicyDocument.ConditionFailed(c.condition())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// match checks whether the resolved field value matches the expected value.
|
|
func (c exactCondition) match(in PostPolicyEvalInput) error {
|
|
got, ok := lookupField(in, c.field)
|
|
if !ok {
|
|
debuglogger.Logf("missing POST policy field %q for condition %s", c.field, c.condition())
|
|
return s3err.InvalidPolicyDocument.ConditionFailed(c.condition())
|
|
}
|
|
if got != c.value {
|
|
debuglogger.Logf("POST policy exact match failed for field %q: got %q want %q", c.field, got, c.value)
|
|
return s3err.InvalidPolicyDocument.ConditionFailed(c.condition())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// coveredField reports which form field this condition authorizes.
|
|
func (c exactCondition) coveredField() string { return c.field }
|
|
|
|
// startsWithCondition represents a policy rule that constrains a field by
|
|
// prefix rather than by exact equality.
|
|
type startsWithCondition struct {
|
|
field string
|
|
prefix string
|
|
rawCondition []byte
|
|
}
|
|
|
|
// isBucketCondition checks if the condition field is 'bucket'
|
|
func (c startsWithCondition) isBucketCondition() bool {
|
|
return c.field == "bucket"
|
|
}
|
|
|
|
// condition returns the original policy expression for failure reporting.
|
|
func (c startsWithCondition) condition() string {
|
|
return string(c.rawCondition)
|
|
}
|
|
|
|
// validate ensures the starts-with condition references a field.
|
|
func (c startsWithCondition) validate() error {
|
|
if strings.TrimSpace(c.field) == "" {
|
|
debuglogger.Logf("empty field in POST policy 'starts-with' condition")
|
|
return s3err.InvalidPolicyDocument.ConditionFailed(c.condition())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// match checks whether the resolved field value satisfies the required prefix.
|
|
func (c startsWithCondition) match(in PostPolicyEvalInput) error {
|
|
got, ok := lookupField(in, c.field)
|
|
if !ok {
|
|
debuglogger.Logf("missing POST policy field %q for condition %s", c.field, c.condition())
|
|
return s3err.InvalidPolicyDocument.ConditionFailed(c.condition())
|
|
}
|
|
if !startsWithMatch(c.field, got, c.prefix) {
|
|
debuglogger.Logf("POST policy starts-with failed for field %q: got %q prefix %q", c.field, got, c.prefix)
|
|
return s3err.InvalidPolicyDocument.ConditionFailed(c.condition())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// coveredField reports which form field this condition authorizes.
|
|
func (c startsWithCondition) coveredField() string { return c.field }
|
|
|
|
// contentLengthRangeCondition enforces the allowed size range of the uploaded
|
|
// object body.
|
|
type contentLengthRangeCondition struct {
|
|
min int64
|
|
max int64
|
|
}
|
|
|
|
// isBucketCondition checks if the condition field is 'bucket'
|
|
func (c contentLengthRangeCondition) isBucketCondition() bool {
|
|
return false
|
|
}
|
|
|
|
// validate accepts any parsed range and leaves size enforcement to match.
|
|
func (c contentLengthRangeCondition) validate() error {
|
|
return nil
|
|
}
|
|
|
|
// match rejects uploads whose content length falls outside the allowed range.
|
|
func (c contentLengthRangeCondition) match(in PostPolicyEvalInput) error {
|
|
if in.ContentLength > c.max {
|
|
debuglogger.Logf("POST policy content length %d exceeds max %d", in.ContentLength, c.max)
|
|
return s3err.GetAPIError(s3err.ErrEntityTooLarge)
|
|
}
|
|
if in.ContentLength < c.min {
|
|
debuglogger.Logf("POST policy content length %d is smaller than min %d", in.ContentLength, c.min)
|
|
return s3err.GetAPIError(s3err.ErrEntityTooSmall)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Content length constraints apply to the uploaded body as a whole rather than
|
|
// to a named form field, so they do not participate in field coverage checks.
|
|
func (c contentLengthRangeCondition) coveredField() string { return "" }
|
|
|
|
// hasConditionForField reports whether the policy covers the supplied form
|
|
// field name.
|
|
func (p *POSTPolicy) hasConditionForField(field string) bool {
|
|
for _, cond := range p.conditions {
|
|
if cond.coveredField() == field {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// lookupField resolves policy field references against the canonical request
|
|
// state and submitted form fields.
|
|
func lookupField(in PostPolicyEvalInput, field string) (string, bool) {
|
|
// bucket and key are validated from the resolved request state
|
|
switch field {
|
|
case "bucket":
|
|
return in.Bucket, true
|
|
case "key":
|
|
return in.Key, true
|
|
}
|
|
|
|
if in.Fields == nil {
|
|
return "", false
|
|
}
|
|
|
|
v, ok := in.Fields[field]
|
|
return v, ok
|
|
}
|
|
|
|
// isIgnoredCoverageField reports whether a submitted field is exempt from the
|
|
// POST policy's field coverage requirement.
|
|
func isIgnoredCoverageField(field string) bool {
|
|
return field == "file" ||
|
|
field == "policy" ||
|
|
field == "x-amz-signature" ||
|
|
strings.HasPrefix(field, "x-ignore-")
|
|
}
|
|
|
|
// startsWithMatch applies AWS's starts-with matching rules for POST policy
|
|
// evaluation.
|
|
func startsWithMatch(field, value, prefix string) bool {
|
|
// AWS special-case:
|
|
// For starts-with on Content-Type, a comma-separated value is interpreted
|
|
// as a list and every entry must satisfy the prefix.
|
|
if field == "content-type" && strings.Contains(value, ",") {
|
|
parts := strings.SplitSeq(value, ",")
|
|
for part := range parts {
|
|
if !strings.HasPrefix(strings.TrimSpace(part), prefix) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
return strings.HasPrefix(value, prefix)
|
|
}
|
|
|
|
// decodeBase64Policy accepts both padded and raw standard base64 encodings for
|
|
// POST policy documents.
|
|
func decodeBase64Policy(s string) ([]byte, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
debuglogger.Logf("empty POST policy")
|
|
return nil, s3err.InvalidPolicyDocument.EmptyPolicy()
|
|
}
|
|
|
|
if raw, err := base64.StdEncoding.DecodeString(s); err == nil {
|
|
return raw, nil
|
|
}
|
|
if raw, err := base64.RawStdEncoding.DecodeString(s); err == nil {
|
|
return raw, nil
|
|
}
|
|
|
|
debuglogger.Logf("invalid POST policy base64 encoding")
|
|
return nil, s3err.InvalidPolicyDocument.InvalidBase64Encoding()
|
|
}
|
|
|
|
// parseExpiration parses the policy expiration timestamp and normalizes it to
|
|
// UTC.
|
|
func parseExpiration(s string) (time.Time, error) {
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t.UTC(), nil
|
|
}
|
|
}
|
|
debuglogger.Logf("invalid POST policy expiration: %s", s)
|
|
return time.Time{}, s3err.InvalidPolicyDocument.InvalidExpiration(s)
|
|
}
|
|
|
|
// parseCondition converts one raw JSON condition into its internal validation
|
|
// and matching form.
|
|
func parseCondition(raw json.RawMessage) (postPolicyCondition, error) {
|
|
// Object form: {"content-type":"application/xml"}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(raw, &obj); err == nil && obj != nil {
|
|
if len(obj) != 1 {
|
|
debuglogger.Logf("POST policy simple condition must have exactly one property: %s", string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.OnePropSimpleCondition()
|
|
}
|
|
|
|
for field, value := range obj {
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
debuglogger.Logf("POST policy simple condition value must be string: %s", string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.InvalidSimpleCondition()
|
|
}
|
|
|
|
return exactCondition{
|
|
field: strings.ToLower(field),
|
|
value: s,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Array form:
|
|
// ["eq", "$acl", "public-read"]
|
|
// ["starts-with", "$key", "user/eric/"]
|
|
// ["content-length-range", 1, 10485760]
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(raw, &arr); err != nil {
|
|
debuglogger.Logf("invalid POST policy condition: %s", string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.InvalidCondition()
|
|
}
|
|
if len(arr) == 0 {
|
|
debuglogger.Logf("POST policy condition missing operation identifier")
|
|
return nil, s3err.InvalidPolicyDocument.MissingConditionOperationIdentifier()
|
|
}
|
|
|
|
var op string
|
|
if err := json.Unmarshal(arr[0], &op); err != nil {
|
|
debuglogger.Logf("invalid POST policy condition operation: %s", string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
|
|
switch op {
|
|
case "eq", "starts-with":
|
|
if len(arr) != 3 {
|
|
debuglogger.Logf("POST policy %s condition has wrong number of arguments: %s", op, string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.IncorrectConditionArgumentsNumber(op)
|
|
}
|
|
|
|
var fieldRef string
|
|
if err := json.Unmarshal(arr[1], &fieldRef); err != nil {
|
|
debuglogger.Logf("invalid POST policy field reference: %s", string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
value, err := rawScalarToString(arr[2])
|
|
if err != nil {
|
|
debuglogger.Logf("invalid POST policy scalar value: %s", string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
|
|
if !strings.HasPrefix(fieldRef, "$") || len(fieldRef) == 1 {
|
|
debuglogger.Logf("invalid POST policy field reference format: %s", fieldRef)
|
|
return nil, s3err.InvalidPolicyDocument.ConditionFailed(string(raw))
|
|
}
|
|
|
|
// Normalize field names so condition checks line up with the parsed form
|
|
// map regardless of how they were written in the policy document.
|
|
field := strings.ToLower(fieldRef[1:])
|
|
if op == "eq" {
|
|
return exactCondition{field: field, value: value, rawCondition: raw}, nil
|
|
}
|
|
|
|
return startsWithCondition{field: field, prefix: value, rawCondition: raw}, nil
|
|
case "content-length-range":
|
|
if len(arr) != 3 {
|
|
debuglogger.Logf("POST policy %s condition has wrong number of arguments: %s", op, string(raw))
|
|
return nil, s3err.InvalidPolicyDocument.IncorrectConditionArgumentsNumber(op)
|
|
}
|
|
|
|
min, err := parseJSONInt64(arr[1])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
max, err := parseJSONInt64(arr[2])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return contentLengthRangeCondition{min: min, max: max}, nil
|
|
default:
|
|
debuglogger.Logf("unknown POST policy operation: %s", op)
|
|
return nil, s3err.InvalidPolicyDocument.UnknownConditionOperation(op)
|
|
}
|
|
}
|
|
|
|
// parseJSONInt64 parses an integer condition operand from either a JSON number
|
|
// or a quoted decimal string.
|
|
func parseJSONInt64(raw json.RawMessage) (int64, error) {
|
|
// try parsing as JSON number
|
|
var num json.Number
|
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
|
dec.UseNumber()
|
|
|
|
if err := dec.Decode(&num); err == nil {
|
|
// Ensure it's a valid int64 (reject floats, exponents, overflow)
|
|
if v, err := num.Int64(); err == nil {
|
|
return v, nil
|
|
}
|
|
debuglogger.Logf("invalid POST policy integer value: %s", string(raw))
|
|
return 0, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
|
|
// AWS also accepts quoted integers here.
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
v, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil {
|
|
debuglogger.Logf("invalid POST policy quoted integer: %s", s)
|
|
return 0, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
debuglogger.Logf("invalid POST policy integer JSON: %s", string(raw))
|
|
return 0, s3err.InvalidPolicyDocument.InvalidJSON()
|
|
}
|
|
|
|
// rawScalarToString converts a JSON scalar that is valid in policy conditions
|
|
// into its string representation.
|
|
func rawScalarToString(raw json.RawMessage) (string, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
|
|
var v any
|
|
if err := decoder.Decode(&v); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x, nil
|
|
case json.Number:
|
|
return x.String(), nil
|
|
default:
|
|
return "", errors.New("unsupported type")
|
|
}
|
|
}
|