Files
seaweedfs/weed/s3api/s3api_object_handlers_postpolicy.go
T
Chris LuandClaude Opus 5.5 6c1eec8703 s3: store the POST Object acl on the uploaded object (#12567)
* s3api: split upload ACL assembly out of preparePutObjectACL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: store POST Object acl and grant fields on the object

The form acl and x-amz-grant-* fields were authorized but never assembled,
so the object kept no ACL: acl=private on a public-read bucket stayed
anonymously readable and invalid values were accepted. Build them with the
same rules as PutObject, which also covers the Block Public Access check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test POST Object stores its upload ACL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: take the POST Object ACL from the signed form only

ACL request headers are not covered by the POST policy, so a holder of a
signed form could add one and have it stored with the upload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject a POST acl field the policy does not cover

The acl form field now sets the stored object ACL, so like the x-amz-*
fields it needs a policy condition, as AWS requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test unsigned ACL headers and uncovered acl fields on POST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: evaluate POST upload policies on the canonical ACL

Policy conditions on the grant headers saw the raw form value, so an
equivalent spelling of a denied grantee passed. Use the PutObject request
shape that the PUT path evaluates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: check bucket policy for POST ACLs with auth disabled

PutObject applies the bucket policy to an explicit upload ACL when IAM is
off; POST now does the same before storing it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject differing acl and x-amz-acl POST fields

Both promote to the canned ACL header, so map order picked the stored ACL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test POST grant canonicalization, auth-off ACL policy, acl conflicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: check the auth-off POST ACL policy after loading the bucket

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: reject an empty acl or x-amz-acl POST field beside a differing one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test the auth-off POST ACL policy on a cold bucket cache and empty acl aliases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: load the bucket before per-key object authorization

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test the POST ACL policy on a cold bucket cache with auth on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: stop per-key object authorization when the bucket fails to load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3api: test the cold-bucket POST ACL allow path and a failed bucket load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 10:36:51 -08:00

458 lines
15 KiB
Go

package s3api
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"github.com/dustin/go-humanize"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"golang.org/x/net/http/httpguts"
)
func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.Request) {
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html
bucket := mux.Vars(r)["bucket"]
glog.V(3).Infof("PostPolicyBucketHandler %s", bucket)
reader, err := r.MultipartReader()
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
return
}
form, err := reader.ReadForm(int64(5 * humanize.MiByte))
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
return
}
defer form.RemoveAll()
fileBody, fileName, fileContentType, fileSize, formValues, err := extractPostPolicyFormValues(form)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
return
}
if fileBody == nil {
s3err.WriteErrorResponse(w, r, s3err.ErrPOSTFileRequired)
return
}
defer fileBody.Close()
formValues.Set("Bucket", bucket)
if fileName != "" && strings.Contains(formValues.Get("Key"), "${filename}") {
formValues.Set("Key", strings.Replace(formValues.Get("Key"), "${filename}", fileName, -1))
}
rawObject := formValues.Get("Key")
if rawObject == "" || !s3_constants.IsValidObjectKey(rawObject) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
object := s3_constants.NormalizeObjectKey(rawObject)
if err := s3a.validateTableBucketObjectPath(bucket, object); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied)
return
}
successRedirect := formValues.Get("success_action_redirect")
successStatus := formValues.Get("success_action_status")
var redirectURL *url.URL
if successRedirect != "" {
redirectURL, err = url.Parse(successRedirect)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
return
}
}
// Verify policy signature. With auth off there are no keys to check it against, as on the other handlers.
var identity *Identity
errCode := s3err.ErrNone
if s3a.iam.isEnabled() {
identity, errCode = s3a.iam.doesPolicySignatureMatch(r.Context(), formValues)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
return
}
if identity != nil {
r = r.WithContext(recordIdentityInContext(r, identity))
if identity.Account != nil {
r.Header.Set(s3_constants.AmzAccountId, identity.Account.Id)
}
}
}
policyBytes, err := base64.StdEncoding.DecodeString(formValues.Get("Policy"))
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
return
}
// Handle policy if it is set.
if len(policyBytes) > 0 {
postPolicyForm, err := policy.ParsePostPolicyForm(string(policyBytes))
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrPostPolicyConditionInvalidFormat)
return
}
// Make sure formValues adhere to policy restrictions.
if err = policy.CheckPostPolicy(formValues, postPolicyForm); err != nil {
glog.V(3).Infof("PostPolicy check failed for bucket %s: %v", bucket, err)
s3err.WriteErrorResponseWithMessage(w, r, s3err.ErrAccessDenied, err.Error())
return
}
// Ensure that the object size is within expected range, also the file size
// should not exceed the maximum single Put size (5 GiB)
lengthRange := postPolicyForm.Conditions.ContentLengthRange
if lengthRange.Valid {
if fileSize < lengthRange.Min {
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooSmall)
return
}
if fileSize > lengthRange.Max {
s3err.WriteErrorResponse(w, r, s3err.ErrEntityTooLarge)
return
}
}
}
// Get ContentType from post formData
// Otherwise from formFile ContentType
contentType := formValues.Get("Content-Type")
if contentType == "" {
contentType = fileContentType
}
r.Header.Set("Content-Type", contentType)
// acl and x-amz-acl both set the canned ACL; differing values are ambiguous.
_, hasAcl := formValues["Acl"]
_, hasAmzAcl := formValues[s3_constants.AmzCannedAcl]
if hasAcl && hasAmzAcl && formValues.Get("Acl") != formValues.Get(s3_constants.AmzCannedAcl) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// Forward validated POST form fields to the underlying PUT as headers.
applyPostPolicyFormHeaders(r, formValues)
if !validHeaderValues(r.Header) {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedPOSTRequest)
return
}
// Authorize the object like the PUT path, with policies seeing the ACL as PutObject presents it.
policyReq, errCode := putObjectACLPolicyRequest(objectKeyPolicyRequest(r, http.MethodPut, bucket, object, ""), s3_constants.ACTION_WRITE, bucket, object)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if errCode := s3a.iam.AuthorizeObjectWrite(policyReq, identity, bucket, object); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
return
}
// An acl or x-amz-grant-* form field rides onto the upload as ACL headers,
// which the PUT path only applies under s3:PutObjectAcl.
if postRequestsAcl(r) {
if errCode := s3a.iam.AuthorizeObjectAclWrite(policyReq, identity, bucket, object); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, refineDenialForBucketName(r, identity, errCode))
return
}
}
versioningState, err := s3a.getVersioningState(bucket)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
// Auto-create bucket if it doesn't exist (requires Admin permission)
if !s3a.handleAutoCreateBucket(w, r, bucket, "PostPolicyBucketHandler") {
return
}
// Re-fetch versioning state to handle race conditions where
// another process might have created the bucket with versioning enabled.
versioningState, err = s3a.getVersioningState(bucket)
}
if err != nil {
glog.Errorf("PostPolicyBucketHandler: versioning state for bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
}
// The bucket load above brings its policy into the engine.
if !s3a.iam.isEnabled() && postRequestsAcl(r) {
if errCode := s3a.checkPutObjectACLPolicy(policyReq, bucket, object); errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
}
r, errCode = s3a.preparePostObjectACL(r, bucket)
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
objectLockEnabled, err := s3a.isObjectLockEnabled(bucket)
if err != nil && !errors.Is(err, filer_pb.ErrNotFound) {
glog.Errorf("PostPolicyBucketHandler: object lock state for bucket %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
return
}
if err := s3a.validateObjectLockHeaders(r, bucket, object, objectLockEnabled); err != nil {
glog.V(2).Infof("PostPolicyBucketHandler: object lock header validation failed for %s/%s: %v", bucket, object, err)
s3err.WriteErrorResponse(w, r, mapValidationErrorToS3Error(err))
return
}
var etag string
var versionId string
var sseMetadata SSEResponseMetadata
switch versioningState {
case s3_constants.VersioningEnabled:
versionId, etag, errCode, sseMetadata = s3a.putVersionedObject(r, bucket, object, fileBody, contentType)
case s3_constants.VersioningSuspended:
etag, errCode, sseMetadata = s3a.putSuspendedVersioningObject(r, bucket, object, fileBody, contentType)
default:
filePath := fmt.Sprintf("%s/%s", s3a.bucketDir(bucket), object)
// Use fileSize, not r.ContentLength: the multipart body inflates ContentLength.
ttlSec := s3a.lifecycleTTLForObjectWrite(bucket, object, fileSize)
etag, errCode, sseMetadata = s3a.putToFiler(r, filePath, fileBody, bucket, object, 1, ttlSec, nil, false, "")
}
if errCode != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, errCode)
return
}
if versionId != "" {
w.Header().Set("x-amz-version-id", versionId)
}
if successRedirect != "" {
// Replace raw query params..
redirectURL.RawQuery = getRedirectPostRawQuery(bucket, object, etag)
w.Header().Set("Location", redirectURL.String())
s3err.WriteEmptyResponse(w, r, http.StatusSeeOther)
return
}
setEtag(w, etag)
// Include SSE response headers (important for bucket-default encryption)
s3a.setSSEResponseHeaders(w, r, sseMetadata)
// Decide what http response to send depending on success_action_status parameter
switch successStatus {
case "201":
resp := PostResponse{
Bucket: bucket,
Key: object,
ETag: `"` + etag + `"`,
Location: w.Header().Get("Location"),
}
s3err.WriteXMLResponse(w, r, http.StatusCreated, resp)
s3err.PostLog(r, http.StatusCreated, s3err.ErrNone)
case "200":
s3err.WriteEmptyResponse(w, r, http.StatusOK)
case "204":
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
default:
s3err.WriteEmptyResponse(w, r, http.StatusNoContent)
}
}
// postPolicyReservedFormFields are multipart form fields that are part of the
// POST Object auth/policy mechanism (or handled explicitly elsewhere in the
// handler) and must not be forwarded to the upload as HTTP headers. Keys are
// already run through http.CanonicalHeaderKey before lookup.
var postPolicyReservedFormFields = map[string]struct{}{
// POST policy signature (V2)
"Policy": {},
"Signature": {},
"Awsaccesskeyid": {},
// POST policy signature (V4)
"X-Amz-Signature": {},
"X-Amz-Credential": {},
"X-Amz-Algorithm": {},
"X-Amz-Date": {},
"X-Amz-Security-Token": {},
// Target descriptors
"Key": {},
"File": {},
"Bucket": {},
// Success actions (handled elsewhere in the handler)
"Success_action_redirect": {},
"Success_action_status": {},
"Redirect": {},
// Content-Type is resolved separately above (from form or file part)
"Content-Type": {},
}
// applyPostPolicyFormHeaders forwards validated POST Object form fields to the
// request headers so they are applied to the resulting PUT. Reserved fields
// that are part of the POST policy mechanism itself (signature, key, etc.) are
// skipped. The acl form field is translated to the X-Amz-Acl header to match
// how AWS promotes the form value to the underlying PUT.
func applyPostPolicyFormHeaders(r *http.Request, formValues http.Header) {
// The policy signs form fields only, so the upload ACL comes from the form.
for _, header := range postAclHeaders {
r.Header.Del(header)
}
for k := range formValues {
if _, reserved := postPolicyReservedFormFields[k]; reserved {
continue
}
switch {
case k == "Acl":
r.Header.Set(s3_constants.AmzCannedAcl, formValues.Get(k))
case k == "Cache-Control",
k == "Expires",
k == "Content-Disposition",
k == "Content-Encoding",
k == "Content-Language":
r.Header.Set(k, formValues.Get(k))
case strings.HasPrefix(k, "X-Amz-"):
r.Header.Set(k, formValues.Get(k))
}
}
}
// validHeaderValues rejects form values carrying control characters, which a
// real PUT could not send and which would corrupt the volume upload framing.
func validHeaderValues(h http.Header) bool {
for _, values := range h {
for _, v := range values {
if !httpguts.ValidHeaderFieldValue(v) {
return false
}
}
}
return true
}
var postAclHeaders = []string{s3_constants.AmzCannedAcl, s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp}
// postRequestsAcl reports whether the POST form fields promoted an ACL.
func postRequestsAcl(r *http.Request) bool {
for _, header := range postAclHeaders {
if r.Header.Get(header) != "" {
return true
}
}
return false
}
// Extract form fields and file data from a HTTP POST Policy
func extractPostPolicyFormValues(form *multipart.Form) (filePart io.ReadCloser, fileName, fileContentType string, fileSize int64, formValues http.Header, err error) {
// / HTML Form values
fileName = ""
fileContentType = ""
// Canonicalize the form values into http.Header.
formValues = make(http.Header)
for k, v := range form.Value {
formValues[http.CanonicalHeaderKey(k)] = v
}
// Validate form values.
if err = validateFormFieldSize(formValues); err != nil {
return nil, "", "", 0, nil, err
}
// this means that filename="" was not specified for file key and Go has
// an ugly way of handling this situation. Refer here
// https://golang.org/src/mime/multipart/formdata.go#L61
if len(form.File) == 0 {
var b = &bytes.Buffer{}
for _, v := range formValues["File"] {
b.WriteString(v)
}
fileSize = int64(b.Len())
filePart = io.NopCloser(b)
return filePart, fileName, fileContentType, fileSize, formValues, nil
}
// Iterator until we find a valid File field and break
for k, v := range form.File {
canonicalFormName := http.CanonicalHeaderKey(k)
if canonicalFormName == "File" {
if len(v) == 0 {
return nil, "", "", 0, nil, errors.New("Invalid arguments specified")
}
// Fetch fileHeader which has the uploaded file information
fileHeader := v[0]
// Set filename
fileName = fileHeader.Filename
// Set contentType
fileContentType = fileHeader.Header.Get("Content-Type")
// Open the uploaded part
filePart, err = fileHeader.Open()
if err != nil {
return nil, "", "", 0, nil, err
}
// Compute file size
fileSize, err = filePart.(io.Seeker).Seek(0, 2)
if err != nil {
return nil, "", "", 0, nil, err
}
// Reset Seek to the beginning
_, err = filePart.(io.Seeker).Seek(0, 0)
if err != nil {
return nil, "", "", 0, nil, err
}
// File found and ready for reading
break
}
}
return filePart, fileName, fileContentType, fileSize, formValues, nil
}
// Validate form field size for s3 specification requirement.
func validateFormFieldSize(formValues http.Header) error {
// Iterate over form values
for k := range formValues {
// Check if value's field exceeds S3 limit
if int64(len(formValues.Get(k))) > int64(1*humanize.MiByte) {
return errors.New("Data size larger than expected")
}
}
// Success.
return nil
}
func getRedirectPostRawQuery(bucket, key, etag string) string {
redirectValues := make(url.Values)
redirectValues.Set("bucket", bucket)
redirectValues.Set("key", key)
redirectValues.Set("etag", "\""+etag+"\"")
return redirectValues.Encode()
}
// Check to see if Policy is signed correctly, returning the signing identity.
// The verified form's access key is recorded in ctx's identity holder.
// Write authorization happens later in the handler via AuthorizeObjectWrite,
// which evaluates the identity's attached IAM policies, session policies, and
// the bucket policy against the real object key; gating here on the legacy
// Actions projection would deny identities those mechanisms allow.
func (iam *IdentityAccessManagement) doesPolicySignatureMatch(ctx context.Context, formValues http.Header) (*Identity, s3err.ErrorCode) {
// For SignV2 - Signature field will be valid
if _, ok := formValues["Signature"]; ok {
return iam.doesPolicySignatureV2Match(ctx, formValues)
}
return iam.doesPolicySignatureV4Match(ctx, formValues)
}