mirror of
https://github.com/versity/versitygw.git
synced 2026-03-27 09:54:59 +00:00
Closes #1648 Fixes #1980 Fixes #1981 This PR implements browser-based POST object uploads for S3-compatible form uploads. It adds support for handling `multipart/form-data` object uploads submitted from browsers, including streaming multipart parsing so file content is not buffered in memory, POST policy decoding and evaluation, SigV4-based form authorization, and integration with the existing `PutObject` backend flow. The implementation covers the full browser POST upload path, including validation of required form fields, credential scope and request date checks, signature verification, metadata extraction from `x-amz-meta-*` fields, checksum field parsing, object tagging conversion from XML into the query-string format expected by `PutObject`, and browser-compatible success handling through `success_action_status` and `success_action_redirect`. It also wires the new flow into the router and metrics layer and adds POST-specific error handling and debug logging across policy parsing, multipart parsing, and POST authorization. AWS S3 also accepts the `redirect` form field alongside `success_action_redirect`, but since AWS has marked `redirect` as deprecated and is planning to remove it, this gateway intentionally does not support it.
356 lines
10 KiB
Go
356 lines
10 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 (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
func encodePolicyForTest(t *testing.T, expiration time.Time, conditions []any, rawBase64 bool) string {
|
|
t.Helper()
|
|
|
|
policy := map[string]any{
|
|
"expiration": expiration.UTC().Format(time.RFC3339),
|
|
"conditions": conditions,
|
|
}
|
|
|
|
b, err := json.Marshal(policy)
|
|
assert.NoError(t, err)
|
|
|
|
if rawBase64 {
|
|
return base64.RawStdEncoding.EncodeToString(b)
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func encodeRawPolicyJSONForTest(t *testing.T, rawJSON string) string {
|
|
t.Helper()
|
|
return base64.StdEncoding.EncodeToString([]byte(rawJSON))
|
|
}
|
|
|
|
func mustParsePolicyForTest(t *testing.T, encoded string) *POSTPolicy {
|
|
t.Helper()
|
|
p, err := ParsePOSTPolicyBase64(encoded)
|
|
assert.NoError(t, err)
|
|
return p
|
|
}
|
|
|
|
func TestParsePOSTPolicyBase64_Success_AndEvaluate(t *testing.T) {
|
|
encoded := encodePolicyForTest(t, time.Now().Add(15*time.Minute), []any{
|
|
map[string]string{"bucket": "photos"},
|
|
[]any{"starts-with", "$key", "uploads/"},
|
|
[]any{"eq", "$x-amz-algorithm", "AWS4-HMAC-SHA256"},
|
|
[]any{"content-length-range", 1, 10},
|
|
}, false)
|
|
|
|
p := mustParsePolicyForTest(t, encoded)
|
|
|
|
assert.NoError(t, p.Evaluate(PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 5,
|
|
Fields: map[string]string{
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
"file": "ignored",
|
|
"policy": encoded,
|
|
"x-amz-signature": "ignored",
|
|
"x-ignore-meta": "ignored",
|
|
},
|
|
}))
|
|
}
|
|
|
|
func TestParsePOSTPolicyBase64_AcceptsRawBase64(t *testing.T) {
|
|
encoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
map[string]string{"bucket": "photos"},
|
|
}, true)
|
|
|
|
_, err := ParsePOSTPolicyBase64(encoded)
|
|
assert.Equal(t, error(nil), err)
|
|
}
|
|
|
|
func TestParsePOSTPolicyBase64_ConcreteParseErrors(t *testing.T) {
|
|
floatRangeEncoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
[]any{"content-length-range", 1.5, 10},
|
|
}, false)
|
|
|
|
unknownOpEncoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
[]any{"contains", "$key", "uploads/"},
|
|
}, false)
|
|
|
|
badQuotedRangeEncoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
[]any{"content-length-range", "abc", 10},
|
|
}, false)
|
|
|
|
tests := []struct {
|
|
name string
|
|
encoded string
|
|
expected error
|
|
}{
|
|
{
|
|
name: "empty policy",
|
|
encoded: " ",
|
|
expected: s3err.InvalidPolicyDocument.EmptyPolicy(),
|
|
},
|
|
{
|
|
name: "invalid base64",
|
|
encoded: "%%%not-base64%%%",
|
|
expected: s3err.InvalidPolicyDocument.InvalidBase64Encoding(),
|
|
},
|
|
{
|
|
name: "invalid json",
|
|
encoded: encodeRawPolicyJSONForTest(t, `{"expiration":`),
|
|
expected: s3err.InvalidPolicyDocument.InvalidJSON(),
|
|
},
|
|
{
|
|
name: "missing expiration",
|
|
encoded: encodeRawPolicyJSONForTest(t, `{
|
|
"conditions":[{"bucket":"photos"}]
|
|
}`),
|
|
expected: s3err.InvalidPolicyDocument.MissingExpiration(),
|
|
},
|
|
{
|
|
name: "missing conditions",
|
|
encoded: encodeRawPolicyJSONForTest(t, `{
|
|
"expiration":"2100-01-01T00:00:00Z"
|
|
}`),
|
|
expected: s3err.InvalidPolicyDocument.MissingConditions(),
|
|
},
|
|
{
|
|
name: "invalid expiration format",
|
|
encoded: encodeRawPolicyJSONForTest(t, `{
|
|
"expiration":"not-a-time",
|
|
"conditions":[{"bucket":"photos"}]
|
|
}`),
|
|
expected: s3err.InvalidPolicyDocument.InvalidExpiration("not-a-time"),
|
|
},
|
|
{
|
|
name: "expired policy",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(-5*time.Minute), []any{
|
|
map[string]string{"bucket": "photos"},
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.PolicyExpired(),
|
|
},
|
|
{
|
|
name: "unknown operation",
|
|
encoded: unknownOpEncoded,
|
|
expected: s3err.InvalidPolicyDocument.UnknownConditionOperation("contains"),
|
|
},
|
|
{
|
|
name: "array condition missing op identifier",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
[]any{},
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.MissingConditionOperationIdentifier(),
|
|
},
|
|
{
|
|
name: "eq wrong number of args",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
[]any{"eq", "$key"},
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.IncorrectConditionArgumentsNumber("eq"),
|
|
},
|
|
{
|
|
name: "content-length-range wrong number of args",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
[]any{"content-length-range", 1},
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.IncorrectConditionArgumentsNumber("content-length-range"),
|
|
},
|
|
{
|
|
name: "invalid simple condition value type",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
map[string]any{"bucket": 1},
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.InvalidSimpleCondition(),
|
|
},
|
|
{
|
|
name: "simple condition with multiple properties",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
map[string]string{"bucket": "photos", "acl": "private"},
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.OnePropSimpleCondition(),
|
|
},
|
|
{
|
|
name: "condition not object or list",
|
|
encoded: encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
123,
|
|
}, false),
|
|
expected: s3err.InvalidPolicyDocument.InvalidCondition(),
|
|
},
|
|
{
|
|
name: "content-length-range with float",
|
|
encoded: floatRangeEncoded,
|
|
expected: s3err.InvalidPolicyDocument.InvalidJSON(),
|
|
},
|
|
{
|
|
name: "content-length-range with invalid quoted int",
|
|
encoded: badQuotedRangeEncoded,
|
|
expected: s3err.InvalidPolicyDocument.ConditionFailed(`["content-length-range","abc",10]`),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := ParsePOSTPolicyBase64(tt.encoded)
|
|
assert.Equal(t, tt.expected, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPOSTPolicyEvaluate_ConcretePolicyRejections(t *testing.T) {
|
|
encoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
map[string]string{"bucket": "photos"},
|
|
[]any{"starts-with", "$key", "uploads/"},
|
|
[]any{"eq", "$x-amz-algorithm", "AWS4-HMAC-SHA256"},
|
|
[]any{"content-length-range", 2, 4},
|
|
}, false)
|
|
|
|
p := mustParsePolicyForTest(t, encoded)
|
|
|
|
tests := []struct {
|
|
name string
|
|
input PostPolicyEvalInput
|
|
expected error
|
|
}{
|
|
{
|
|
name: "extra field",
|
|
input: PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 3,
|
|
Fields: map[string]string{
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
"acl": "private",
|
|
},
|
|
},
|
|
expected: s3err.InvalidPolicyDocument.ExtraInputField("acl"),
|
|
},
|
|
{
|
|
name: "bucket mismatch",
|
|
input: PostPolicyEvalInput{
|
|
Bucket: "other-bucket",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 3,
|
|
Fields: map[string]string{
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
},
|
|
},
|
|
expected: s3err.InvalidPolicyDocument.ConditionFailed(`["eq", "$bucket", "photos"]`),
|
|
},
|
|
{
|
|
name: "key prefix mismatch",
|
|
input: PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "tmp/image.jpg",
|
|
ContentLength: 3,
|
|
Fields: map[string]string{
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
},
|
|
},
|
|
expected: s3err.InvalidPolicyDocument.ConditionFailed(`["starts-with","$key","uploads/"]`),
|
|
},
|
|
{
|
|
name: "missing required field",
|
|
input: PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 3,
|
|
Fields: map[string]string{},
|
|
},
|
|
expected: s3err.InvalidPolicyDocument.ConditionFailed(`["eq","$x-amz-algorithm","AWS4-HMAC-SHA256"]`),
|
|
},
|
|
{
|
|
name: "content too large",
|
|
input: PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 10,
|
|
Fields: map[string]string{
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
},
|
|
},
|
|
expected: s3err.GetAPIError(s3err.ErrEntityTooLarge),
|
|
},
|
|
{
|
|
name: "content too small",
|
|
input: PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 1,
|
|
Fields: map[string]string{
|
|
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
|
},
|
|
},
|
|
expected: s3err.GetAPIError(s3err.ErrEntityTooSmall),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := p.Evaluate(tt.input)
|
|
assert.Equal(t, tt.expected, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPOSTPolicyEvaluate_IgnoresAllowedCoverageFields(t *testing.T) {
|
|
encoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
map[string]string{"bucket": "photos"},
|
|
[]any{"starts-with", "$key", "uploads/"},
|
|
}, false)
|
|
|
|
p := mustParsePolicyForTest(t, encoded)
|
|
|
|
err := p.Evaluate(PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 3,
|
|
Fields: map[string]string{
|
|
"file": "ignored",
|
|
"policy": "ignored",
|
|
"x-amz-signature": "ignored",
|
|
"x-ignore-meta": "ignored",
|
|
},
|
|
})
|
|
assert.Equal(t, error(nil), err)
|
|
}
|
|
|
|
func TestPOSTPolicyEvaluate_ContentTypeStartsWithList_AllEntriesMustMatch(t *testing.T) {
|
|
encoded := encodePolicyForTest(t, time.Now().Add(5*time.Minute), []any{
|
|
map[string]string{"bucket": "photos"},
|
|
[]any{"starts-with", "$key", "uploads/"},
|
|
[]any{"starts-with", "$Content-Type", "image/"},
|
|
}, false)
|
|
|
|
p := mustParsePolicyForTest(t, encoded)
|
|
|
|
err := p.Evaluate(PostPolicyEvalInput{
|
|
Bucket: "photos",
|
|
Key: "uploads/image.jpg",
|
|
ContentLength: 3,
|
|
Fields: map[string]string{
|
|
"content-type": "image/png,text/plain",
|
|
},
|
|
})
|
|
assert.Equal(t, s3err.InvalidPolicyDocument.ConditionFailed(`["starts-with","$Content-Type","image/"]`), err)
|
|
}
|