Files
seaweedfs/weed/s3api/s3api_path_validation.go
T
Chris Lu 6848cdf9e1 s3: close seaweedfs-quota policy-confusion gap (#11409)
* s3: count seaweedfs-quota as an operation subresource

PUT /bucket?policy&seaweedfs-quota was not rejected by
hasAmbiguousSubresource because operationSubresources omitted the
seaweedfs-quota key. The router then picks the policy route
(registered first) while the IAM action resolver may resolve the
request to s3:PutBucketQuota, letting a quota-only identity write a
bucket policy. Reject the combination before routing, matching the
fix for policy&tagging (#10987).

* s3: resolve seaweedfs-quota after other bucket subresources

The quota routes are registered last among the bucket subresource
routes, but the action resolver found seaweedfs-quota inside the
unordered bucketQueryActions map, so a request carrying it alongside
another selector could be authorized as the quota operation while the
router served the earlier-registered handler. Resolve it explicitly at
the end so the resolver agrees with the router, mirroring how
list-type is handled.

* s3: count resolver subresources in the ambiguity guard

hasAmbiguousSubresource only counted operationSubresources, so adding
a query parameter to the action resolver without updating that list
reopened the authorize-one-serve-another gap. Count bucketQueryActions
keys as operation selectors too, and add a test that walks the
registered routes and fails on any query key that is neither an
operation subresource nor a known modifier.
2026-09-20 23:34:57 -07:00

128 lines
4.3 KiB
Go

package s3api
import (
"net/http"
"net/url"
"strings"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
func hasPathSegmentQuery(rawQuery string) bool {
if strings.Contains(rawQuery, "versionId") || strings.Contains(rawQuery, "uploadId") {
return true
}
if !strings.Contains(rawQuery, "%") {
return false
}
for rawQuery != "" {
field := rawQuery
if i := strings.IndexByte(rawQuery, '&'); i >= 0 {
field, rawQuery = rawQuery[:i], rawQuery[i+1:]
} else {
rawQuery = ""
}
if i := strings.IndexByte(field, '='); i >= 0 {
field = field[:i]
}
if !strings.Contains(field, "%") {
continue
}
key, err := url.QueryUnescape(field)
if err == nil && (key == "versionId" || key == "uploadId") {
return true
}
}
return false
}
// operationSubresources are the query keys that select which operation a request
// is, so at most one may appear. The router matches them in registration order
// and the IAM action resolver in its own, so a request carrying two is
// authorized as one operation and served as another: `PUT /bucket?policy&tagging`
// authorizes as PutBucketTagging and runs PutBucketPolicy. Keys left out here
// (versionId, partNumber, prefix, ...) modify an operation instead of selecting
// one and may accompany any of these.
var operationSubresources = map[string]bool{
"accelerate": true, "acl": true, "analytics": true, "attributes": true,
"cors": true, "delete": true, "encryption": true, "intelligent-tiering": true,
"inventory": true, "legal-hold": true, "lifecycle": true, "list-type": true,
"location": true, "logging": true, "metrics": true, "notification": true,
"object-lock": true, "ownershipControls": true, "policy": true, "policyStatus": true,
"publicAccessBlock": true, "renameObject": true, "replication": true,
"requestPayment": true, "retention": true, "seaweedfs-quota": true,
"tagging": true, "uploadId": true,
"uploads": true, "versioning": true, "versions": true, "website": true,
}
func hasAmbiguousSubresource(query url.Values) bool {
seen := 0
for key := range query {
// bucketQueryActions keys select an operation by definition, so they
// count even if operationSubresources was not updated for them.
if _, ok := bucketQueryActions[key]; !ok && !operationSubresources[key] {
continue
}
if seen++; seen > 1 {
return true
}
}
return false
}
func hasInvalidPathSegment(values []string) bool {
for _, value := range values {
if value != "" && !s3_constants.IsValidPathSegment(value) {
return true
}
}
return false
}
// validateRequestPath rejects requests whose captured {bucket}/{object} mux
// vars would normalize to a parent-directory traversal once joined into a
// filer path. The router runs with mux.NewRouter().SkipClean(true), so
// segments like `..` survive routing; the filer's util.JoinPath later collapses
// them via filepath.Join. Without this guard, `GET /bucket-A/../evil-bucket/k`
// matches as bucket=bucket-A, object=../evil-bucket/k, the filer resolves the
// read against evil-bucket, while IAM authorizes against bucket-A.
func validateRequestPath(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
// When a var is in the matched route it must be non-empty: an empty
// bucket would let downstream path.Join collapse it and let the object
// key pick the bucket.
if bucket, ok := vars["bucket"]; ok {
if bucket == "" || !s3_constants.IsValidBucketName(bucket) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
}
if object, ok := vars["object"]; ok {
if object == "" || !s3_constants.IsValidObjectKey(object) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
}
if r.URL.RawQuery != "" {
query := r.URL.Query()
if hasAmbiguousSubresource(query) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
// versionId and uploadId are later used as filer entry names, and
// the encoded spelling of either still names one.
if hasPathSegmentQuery(r.URL.RawQuery) {
if hasInvalidPathSegment(query["versionId"]) || hasInvalidPathSegment(query["uploadId"]) {
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest)
return
}
}
}
next.ServeHTTP(w, r)
})
}