Files
seaweedfs/weed/s3api/s3api_bucket_handlers_misc.go
T
Chris Lu 1354b58675 s3: stop unrouted bucket subresources from being answered with a listing (#10814)
* s3: answer GetBucketReplication, GetBucketWebsite and GetBucketNotificationConfiguration

None of the three had a route, so they reached the unconstrained ListObjectsV1
catch-all and a client asking for a bucket's replication config got 200 and a
<ListBucketResult> back. Replication and website report their configuration as
absent the way AWS does; notification returns the empty configuration AWS
returns for a bucket with no events wired up.

* s3: stop an unrouted bucket subresource from being answered with a listing

ListObjectsV1 is the catch-all GET on a bucket, so every subresource without a
route of its own - ?torrent today, whatever AWS adds next - came back 200 with a
<ListBucketResult>. A client that asked for a configuration and got a listing
either fails its XML decode in a way that reads like corruption, or worse,
tolerantly parses it. Refuse the request instead.

The allow-list is the ListObjects parameters rather than the subresources,
so a new one fails closed. Presigned URLs sign their credentials into the
query string, so X-Amz-* and the SigV2 trio have to stay listable.
2026-08-18 16:03:08 -07:00

198 lines
7.1 KiB
Go

package s3api
import (
"encoding/xml"
"errors"
"io"
"net/http"
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
)
// putBucketRequestPaymentMaxBodyBytes caps the request body for PutBucketRequestPayment
// to prevent DoS via large payloads. The valid payload is a few hundred bytes.
const putBucketRequestPaymentMaxBodyBytes = 64 * 1024
type policyStatusResponse struct {
XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ PolicyStatus"`
IsPublic bool `xml:"IsPublic"`
}
type accelerateConfigurationResponse struct {
XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ AccelerateConfiguration"`
Status string `xml:"Status"`
}
type bucketLoggingStatusResponse struct {
XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ BucketLoggingStatus"`
}
type notificationConfigurationResponse struct {
XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ NotificationConfiguration"`
}
// GetBucketPolicyStatusHandler reports whether the bucket policy grants public access.
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketPolicyStatus.html
func (s3a *S3ApiServer) GetBucketPolicyStatusHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
policyDoc, err := s3a.getBucketPolicy(bucket)
if err != nil {
if errors.Is(err, ErrPolicyNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucketPolicy)
} else if errors.Is(err, ErrBucketNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
} else {
glog.Errorf("GetBucketPolicyStatusHandler load policy %s: %v", bucket, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
return
}
writeSuccessResponseXML(w, r, policyStatusResponse{IsPublic: isPolicyPublic(policyDoc)})
}
// isPolicyPublic returns true if any Allow statement grants access to "*" without restricting conditions.
func isPolicyPublic(doc *policy_engine.PolicyDocument) bool {
if doc == nil {
return false
}
for _, st := range doc.Statement {
if st.Effect != policy_engine.PolicyEffectAllow {
continue
}
if len(st.Condition) > 0 {
continue
}
for _, p := range st.Principal.Strings() {
if p == "*" {
return true
}
}
}
return false
}
// PutBucketRequestPaymentHandler accepts only Payer=BucketOwner; Requester is rejected.
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketRequestPayment.html
func (s3a *S3ApiServer) PutBucketRequestPaymentHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, putBucketRequestPaymentMaxBodyBytes)
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
var cfg RequestPaymentConfiguration
if err := xml.Unmarshal(body, &cfg); err != nil {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
if cfg.Payer != "BucketOwner" {
s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML)
return
}
writeSuccessResponseEmpty(w, r)
}
// GetBucketAccelerateConfigurationHandler returns a static Suspended status.
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketAccelerateConfiguration.html
func (s3a *S3ApiServer) GetBucketAccelerateConfigurationHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
writeSuccessResponseXML(w, r, accelerateConfigurationResponse{Status: "Suspended"})
}
// GetBucketLoggingHandler returns an empty BucketLoggingStatus (logging disabled).
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketLogging.html
func (s3a *S3ApiServer) GetBucketLoggingHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
writeSuccessResponseXML(w, r, bucketLoggingStatusResponse{})
}
// GetBucketNotificationConfigurationHandler returns an empty configuration; SeaweedFS
// has no bucket event notifications, and AWS answers an unconfigured bucket the same way.
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketNotificationConfiguration.html
func (s3a *S3ApiServer) GetBucketNotificationConfigurationHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
writeSuccessResponseXML(w, r, notificationConfigurationResponse{})
}
// GetBucketReplicationHandler reports that no replication configuration exists.
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketReplication.html
func (s3a *S3ApiServer) GetBucketReplicationHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
s3err.WriteErrorResponse(w, r, s3err.ErrReplicationConfigurationNotFound)
}
// GetBucketWebsiteHandler reports that no website configuration exists.
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetBucketWebsite.html
func (s3a *S3ApiServer) GetBucketWebsiteHandler(w http.ResponseWriter, r *http.Request) {
bucket, _ := s3_constants.GetBucketAndObject(r)
if err := s3a.checkBucket(r, bucket); err != s3err.ErrNone {
s3err.WriteErrorResponse(w, r, err)
return
}
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchWebsiteConfiguration)
}
// listObjectsQueryParams are the query keys a ListObjects/ListObjectsV2 request may
// carry. x-id is stamped by the AWS SDKs and carries no meaning for the server; the
// rest of the non-listing keys are what a presigned URL signs into the query string.
var listObjectsQueryParams = map[string]bool{
"prefix": true, "delimiter": true, "marker": true, "max-keys": true,
"encoding-type": true, "list-type": true, "continuation-token": true,
"start-after": true, "fetch-owner": true, "expected-bucket-owner": true,
"x-id": true,
// SigV2 presigned URLs.
"AWSAccessKeyId": true, "Signature": true, "Expires": true,
}
// unroutedBucketSubresource names a query key on a bucket GET that no route claimed.
// Everything SeaweedFS implements is matched by its own route before the ListObjects
// catch-all, so anything left is a subresource it does not implement - and answering
// it with a bucket listing is worse than saying so.
func unroutedBucketSubresource(r *http.Request) (string, bool) {
for key := range r.URL.Query() {
if listObjectsQueryParams[key] || strings.HasPrefix(key, "X-Amz-") {
continue
}
return key, true
}
return "", false
}