diff --git a/api/data/info.go b/api/data/info.go index fe772f9b1..ab3f24b54 100644 --- a/api/data/info.go +++ b/api/data/info.go @@ -8,7 +8,10 @@ import ( "github.com/nspcc-dev/neofs-api-go/pkg/owner" ) -const bktVersionSettingsObject = ".s3-versioning-settings" +const ( + bktVersionSettingsObject = ".s3-versioning-settings" + bktCORSConfigurationObject = ".s3-cors" +) type ( // BucketInfo stores basic bucket data. @@ -41,6 +44,9 @@ type ( // SettingsObjectName is system name for bucket settings file. func (b *BucketInfo) SettingsObjectName() string { return bktVersionSettingsObject } +// CORSObjectName returns system name for bucket CORS configuration file. +func (b *BucketInfo) CORSObjectName() string { return bktCORSConfigurationObject } + // Version returns object version from ObjectInfo. func (o *ObjectInfo) Version() string { return o.ID.String() } diff --git a/api/handler/api.go b/api/handler/api.go index 1040746fd..452dea0df 100644 --- a/api/handler/api.go +++ b/api/handler/api.go @@ -19,6 +19,7 @@ type ( // Config contains data which handler need to keep. Config struct { DefaultPolicy *netmap.PlacementPolicy + DefaultMaxAge int } ) diff --git a/api/handler/cors.go b/api/handler/cors.go new file mode 100644 index 000000000..19e4ae80d --- /dev/null +++ b/api/handler/cors.go @@ -0,0 +1,298 @@ +package handler + +import ( + "encoding/xml" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/nspcc-dev/neofs-s3-gw/api" + "github.com/nspcc-dev/neofs-s3-gw/api/errors" + "github.com/nspcc-dev/neofs-s3-gw/api/layer" +) + +type ( + // CORSConfiguration stores CORS configuration of a request. + CORSConfiguration struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ CORSConfiguration" json:"-"` + CORSRules []CORSRule `xml:"CORSRule" json:"CORSRules"` + } + // CORSRule stores rules for CORS in a bucket. + CORSRule struct { + ID string `xml:"ID,omitempty" json:"ID,omitempty"` + AllowedHeaders []string `xml:"AllowedHeader" json:"AllowedHeaders"` + AllowedMethods []string `xml:"AllowedMethod" json:"AllowedMethods"` + AllowedOrigins []string `xml:"AllowedOrigin" json:"AllowedOrigins"` + ExposeHeaders []string `xml:"ExposeHeader" json:"ExposeHeaders"` + MaxAgeSeconds int `xml:"MaxAgeSeconds,omitempty" json:"MaxAgeSeconds,omitempty"` + } +) + +const ( + // DefaultMaxAge -- default value of Access-Control-Max-Age if this value is not set in a rule. + DefaultMaxAge = 600 + wildcard = "*" +) + +var supportedMethods = map[string]struct{}{"GET": {}, "HEAD": {}, "POST": {}, "PUT": {}, "DELETE": {}} + +func (h *handler) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + + bktInfo, err := h.obj.GetBucketInfo(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not get bucket info", reqInfo, err) + return + } + + if err = checkOwner(bktInfo, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + info, err := h.obj.GetBucketCORS(r.Context(), bktInfo) + if err != nil { + h.logAndSendError(w, "could not get cors", reqInfo, err) + return + } + + api.WriteResponse(w, http.StatusOK, info, api.MimeNone) +} + +func (h *handler) PutBucketCorsHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + + bktInfo, err := h.obj.GetBucketInfo(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not get bucket info", reqInfo, err) + return + } + + if err = checkOwner(bktInfo, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + cors := &CORSConfiguration{} + if err := xml.NewDecoder(r.Body).Decode(cors); err != nil { + h.logAndSendError(w, "could not parse cors configuration", reqInfo, err) + return + } + if cors.CORSRules == nil { + h.logAndSendError(w, "could not parse cors rules", reqInfo, errors.GetAPIError(errors.ErrMalformedXML)) + return + } + + if err = checkCORS(cors); err != nil { + h.logAndSendError(w, "invalid cors configuration", reqInfo, err) + return + } + + xml, err := xml.Marshal(cors) + if err != nil { + h.logAndSendError(w, "could not encode cors configuration to xml", reqInfo, err) + return + } + + p := &layer.PutCORSParams{ + BktInfo: bktInfo, + CORSConfiguration: xml, + } + + if err = h.obj.PutBucketCORS(r.Context(), p); err != nil { + h.logAndSendError(w, "could not put cors configuration", reqInfo, err) + return + } + + api.WriteSuccessResponseHeadersOnly(w) +} + +func (h *handler) DeleteBucketCorsHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + + bktInfo, err := h.obj.GetBucketInfo(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not get bucket info", reqInfo, err) + return + } + + if err = checkOwner(bktInfo, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + if err := h.obj.DeleteBucketCORS(r.Context(), bktInfo); err != nil { + h.logAndSendError(w, "could not delete cors", reqInfo, err) + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h *handler) AppendCORSHeaders(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + return + } + origin := r.Header.Get(api.Origin) + if origin == "" { + return + } + reqInfo := api.GetReqInfo(r.Context()) + if reqInfo.BucketName == "" { + return + } + bktInfo, err := h.obj.GetBucketInfo(r.Context(), reqInfo.BucketName) + if err != nil { + return + } + + info, err := h.obj.GetBucketCORS(r.Context(), bktInfo) + if err != nil { + return + } + cors := &CORSConfiguration{} + if err = xml.Unmarshal(info, cors); err != nil { + return + } + withCredentials := r.Header.Get(api.Authorization) != "" + + for _, rule := range cors.CORSRules { + for _, o := range rule.AllowedOrigins { + if o == origin { + for _, m := range rule.AllowedMethods { + if m == r.Method { + w.Header().Set(api.AccessControlAllowOrigin, origin) + w.Header().Set(api.AccessControlAllowCredentials, "true") + w.Header().Set(api.Vary, api.Origin) + return + } + } + } + if o == wildcard { + for _, m := range rule.AllowedMethods { + if m == r.Method { + if withCredentials { + w.Header().Set(api.AccessControlAllowOrigin, origin) + w.Header().Set(api.AccessControlAllowCredentials, "true") + w.Header().Set(api.Vary, api.Origin) + } else { + w.Header().Set(api.AccessControlAllowOrigin, o) + } + return + } + } + } + } + } +} + +func (h *handler) Preflight(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + bktInfo, err := h.obj.GetBucketInfo(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not get bucket info", reqInfo, err) + return + } + + origin := r.Header.Get(api.Origin) + if origin == "" { + h.logAndSendError(w, "origin request header needed", reqInfo, errors.GetAPIError(errors.ErrBadRequest)) + } + + method := r.Header.Get(api.AccessControlRequestMethod) + if method == "" { + h.logAndSendError(w, "Access-Control-Request-Method request header needed", reqInfo, errors.GetAPIError(errors.ErrBadRequest)) + return + } + + var headers []string + requestHeaders := r.Header.Get(api.AccessControlRequestHeaders) + if requestHeaders != "" { + headers = strings.Split(requestHeaders, ", ") + } + + info, err := h.obj.GetBucketCORS(r.Context(), bktInfo) + if err != nil { + h.logAndSendError(w, "could not get cors", reqInfo, err) + return + } + cors := &CORSConfiguration{} + if err = xml.Unmarshal(info, cors); err != nil { + h.logAndSendError(w, "could not parse cors configuration", reqInfo, err) + return + } + + for _, rule := range cors.CORSRules { + for _, o := range rule.AllowedOrigins { + if o == origin || o == wildcard { + for _, m := range rule.AllowedMethods { + if m == method { + if !checkSubslice(rule.AllowedHeaders, headers) { + continue + } + w.Header().Set(api.AccessControlAllowOrigin, o) + w.Header().Set(api.AccessControlAllowMethods, strings.Join(rule.AllowedMethods, ", ")) + if headers != nil { + w.Header().Set(api.AccessControlAllowHeaders, requestHeaders) + } + if rule.ExposeHeaders != nil { + w.Header().Set(api.AccessControlExposeHeaders, strings.Join(rule.ExposeHeaders, ", ")) + } + if rule.MaxAgeSeconds > 0 || rule.MaxAgeSeconds == -1 { + w.Header().Set(api.AccessControlMaxAge, strconv.Itoa(rule.MaxAgeSeconds)) + } else { + w.Header().Set(api.AccessControlMaxAge, strconv.Itoa(h.cfg.DefaultMaxAge)) + } + if o != wildcard { + w.Header().Set(api.AccessControlAllowCredentials, "true") + } + api.WriteSuccessResponseHeadersOnly(w) + return + } + } + } + } + } + h.logAndSendError(w, "Forbidden", reqInfo, errors.GetAPIError(errors.ErrAccessDenied)) +} + +func checkCORS(cors *CORSConfiguration) error { + for _, r := range cors.CORSRules { + for _, m := range r.AllowedMethods { + if _, ok := supportedMethods[m]; !ok { + return fmt.Errorf("unsupported HTTP method in CORS config %s", m) + } + } + for _, h := range r.ExposeHeaders { + if h == wildcard { + return fmt.Errorf("ExposeHeader \"*\" contains wildcard. We currently do not support wildcard " + + "for ExposeHeader") + } + } + } + return nil +} + +func checkSubslice(slice []string, subSlice []string) bool { + if sliceContains(slice, wildcard) { + return true + } + if len(subSlice) > len(slice) { + return false + } + for _, r := range subSlice { + if !sliceContains(slice, r) { + return false + } + } + return true +} + +func sliceContains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} diff --git a/api/handler/unimplemented.go b/api/handler/unimplemented.go index 48637e6e1..298180e92 100644 --- a/api/handler/unimplemented.go +++ b/api/handler/unimplemented.go @@ -59,10 +59,6 @@ func (h *handler) GetBucketEncryptionHandler(w http.ResponseWriter, r *http.Requ h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } -func (h *handler) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - func (h *handler) GetBucketWebsiteHandler(w http.ResponseWriter, r *http.Request) { h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } diff --git a/api/headers.go b/api/headers.go index 41936db6f..348ad845d 100644 --- a/api/headers.go +++ b/api/headers.go @@ -46,6 +46,19 @@ const ( AmzSourceExpectedBucketOwner = "X-Amz-Source-Expected-Bucket-Owner" ContainerID = "X-Container-Id" + + AccessControlAllowOrigin = "Access-Control-Allow-Origin" + AccessControlAllowMethods = "Access-Control-Allow-Methods" + AccessControlExposeHeaders = "Access-Control-Expose-Headers" + AccessControlAllowHeaders = "Access-Control-Allow-Headers" + AccessControlMaxAge = "Access-Control-Max-Age" + AccessControlAllowCredentials = "Access-Control-Allow-Credentials" + + Origin = "Origin" + AccessControlRequestMethod = "Access-Control-Request-Method" + AccessControlRequestHeaders = "Access-Control-Request-Headers" + + Vary = "Vary" ) // S3 request query params. diff --git a/api/layer/cors.go b/api/layer/cors.go new file mode 100644 index 000000000..87033e36f --- /dev/null +++ b/api/layer/cors.go @@ -0,0 +1,42 @@ +package layer + +import ( + "context" + + "github.com/nspcc-dev/neofs-s3-gw/api/data" + "github.com/nspcc-dev/neofs-s3-gw/api/errors" +) + +func (n *layer) PutBucketCORS(ctx context.Context, p *PutCORSParams) error { + s := &PutSystemObjectParams{ + BktInfo: p.BktInfo, + ObjName: p.BktInfo.CORSObjectName(), + Metadata: map[string]string{}, + Prefix: "", + Payload: p.CORSConfiguration, + } + + _, err := n.putSystemObject(ctx, s) + + return err +} + +func (n *layer) GetBucketCORS(ctx context.Context, bktInfo *data.BucketInfo) ([]byte, error) { + obj, err := n.getSystemObject(ctx, bktInfo, bktInfo.CORSObjectName()) + if err != nil { + if errors.IsS3Error(err, errors.ErrNoSuchKey) { + return nil, errors.GetAPIError(errors.ErrNoSuchCORSConfiguration) + } + return nil, err + } + + if obj.Payload() == nil { + return nil, errors.GetAPIError(errors.ErrInternalError) + } + + return obj.Payload(), nil +} + +func (n *layer) DeleteBucketCORS(ctx context.Context, bktInfo *data.BucketInfo) error { + return n.deleteSystemObject(ctx, bktInfo, bktInfo.CORSObjectName()) +} diff --git a/api/layer/layer.go b/api/layer/layer.go index 147a67624..b959cded9 100644 --- a/api/layer/layer.go +++ b/api/layer/layer.go @@ -91,6 +91,12 @@ type ( Settings *BucketSettings } + // PutCORSParams stores PutCORS request parameters. + PutCORSParams struct { + BktInfo *data.BucketInfo + CORSConfiguration []byte + } + // BucketSettings stores settings such as versioning. BucketSettings struct { VersioningEnabled bool @@ -168,6 +174,10 @@ type ( PutBucketVersioning(ctx context.Context, p *PutVersioningParams) (*data.ObjectInfo, error) GetBucketVersioning(ctx context.Context, name string) (*BucketSettings, error) + PutBucketCORS(ctx context.Context, p *PutCORSParams) error + GetBucketCORS(ctx context.Context, bktInfo *data.BucketInfo) ([]byte, error) + DeleteBucketCORS(ctx context.Context, bktInfo *data.BucketInfo) error + ListBuckets(ctx context.Context) ([]*data.BucketInfo, error) GetBucketInfo(ctx context.Context, name string) (*data.BucketInfo, error) GetBucketACL(ctx context.Context, name string) (*BucketACL, error) @@ -346,7 +356,7 @@ func (n *layer) GetObject(ctx context.Context, p *GetObjectParams) error { params.Range = objRange _, err = n.objectRange(ctx, params) } else { - _, err = n.objectGet(ctx, params) + _, err = n.objectGetWithPayloadWriter(ctx, params) } if err != nil { diff --git a/api/layer/object.go b/api/layer/object.go index 2a60c4601..66ee9bfb0 100644 --- a/api/layer/object.go +++ b/api/layer/object.go @@ -104,14 +104,20 @@ func (n *layer) objectHead(ctx context.Context, cid *cid.ID, oid *object.ID) (*o return n.pool.GetObjectHeader(ctx, ops, n.BearerOpt(ctx)) } -// objectGet and write it into provided io.Reader. -func (n *layer) objectGet(ctx context.Context, p *getParams) (*object.Object, error) { +// objectGetWithPayloadWriter and write it into provided io.Reader. +func (n *layer) objectGetWithPayloadWriter(ctx context.Context, p *getParams) (*object.Object, error) { // prepare length/offset writer w := newWriter(p.Writer, p.offset, p.length) ops := new(client.GetObjectParams).WithAddress(newAddress(p.cid, p.oid)).WithPayloadWriter(w) return n.pool.GetObject(ctx, ops, n.BearerOpt(ctx)) } +// objectGet returns an object with payload in the object. +func (n *layer) objectGet(ctx context.Context, cid *cid.ID, oid *object.ID) (*object.Object, error) { + ops := new(client.GetObjectParams).WithAddress(newAddress(cid, oid)) + return n.pool.GetObject(ctx, ops, n.BearerOpt(ctx)) +} + // objectRange gets object range and writes it into provided io.Writer. func (n *layer) objectRange(ctx context.Context, p *getParams) ([]byte, error) { w := newWriter(p.Writer, p.offset, p.length) diff --git a/api/layer/system_object.go b/api/layer/system_object.go index f9803d27a..468cc0f12 100644 --- a/api/layer/system_object.go +++ b/api/layer/system_object.go @@ -107,19 +107,17 @@ func (n *layer) getSystemObject(ctx context.Context, bktInfo *data.BucketInfo, o objInfo := versions.getLast() - buf := new(bytes.Buffer) - p := &getParams{ - Writer: buf, - cid: bktInfo.CID, - oid: objInfo.ID, - } - - obj, err := n.objectGet(ctx, p) + obj, err := n.objectGet(ctx, bktInfo.CID, objInfo.ID) if err != nil { return nil, err } - obj.ToV2().SetPayload(buf.Bytes()) + if err = n.systemCache.Put(systemObjectKey(bktInfo, objName), obj); err != nil { + n.log.Warn("couldn't put system meta to objects cache", + zap.Stringer("object id", obj.ID()), + zap.Stringer("bucket id", obj.ContainerID()), + zap.Error(err)) + } return obj, nil } diff --git a/api/router.go b/api/router.go index 0436e3c6e..92ce92149 100644 --- a/api/router.go +++ b/api/router.go @@ -44,6 +44,8 @@ type ( GetBucketACLHandler(http.ResponseWriter, *http.Request) PutBucketACLHandler(http.ResponseWriter, *http.Request) GetBucketCorsHandler(http.ResponseWriter, *http.Request) + PutBucketCorsHandler(http.ResponseWriter, *http.Request) + DeleteBucketCorsHandler(http.ResponseWriter, *http.Request) GetBucketWebsiteHandler(http.ResponseWriter, *http.Request) GetBucketAccelerateHandler(http.ResponseWriter, *http.Request) GetBucketRequestPaymentHandler(http.ResponseWriter, *http.Request) @@ -77,6 +79,8 @@ type ( DeleteBucketEncryptionHandler(http.ResponseWriter, *http.Request) DeleteBucketHandler(http.ResponseWriter, *http.Request) ListBucketsHandler(http.ResponseWriter, *http.Request) + Preflight(w http.ResponseWriter, r *http.Request) + AppendCORSHeaders(w http.ResponseWriter, r *http.Request) } // mimeType represents various MIME type used API responses. @@ -131,6 +135,15 @@ func setRequestID(h http.Handler) http.Handler { }) } +func appendCORS(handler Handler) mux.MiddlewareFunc { + return func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.AppendCORSHeaders(w, r) + h.ServeHTTP(w, r) + }) + } +} + func logErrorResponse(l *zap.Logger) mux.MiddlewareFunc { return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -197,6 +210,11 @@ func Attach(r *mux.Router, domains []string, m MaxClients, h Handler, center aut for _, bucket := range buckets { // Object operations // HeadObject + bucket.Use( + // -- append CORS headers to a response for + appendCORS(h), + ) + bucket.Methods(http.MethodOptions).HandlerFunc(m.Handle(metrics.APIStats("preflight", h.Preflight))).Name("Options") bucket.Methods(http.MethodHead).Path("/{object:.+}").HandlerFunc( m.Handle(metrics.APIStats("headobject", h.HeadObjectHandler))).Name("HeadObject") // CopyObjectPart @@ -296,7 +314,15 @@ func Attach(r *mux.Router, domains []string, m MaxClients, h Handler, center aut bucket.Methods(http.MethodGet).HandlerFunc( m.Handle(metrics.APIStats("getbucketencryption", h.GetBucketEncryptionHandler))).Queries("encryption", ""). Name("GetBucketEncryption") - + bucket.Methods(http.MethodGet).HandlerFunc( + m.Handle(metrics.APIStats("getbucketcors", h.GetBucketCorsHandler))).Queries("cors", ""). + Name("GetBucketCors") + bucket.Methods(http.MethodPut).HandlerFunc( + m.Handle(metrics.APIStats("putbucketcors", h.PutBucketCorsHandler))).Queries("cors", ""). + Name("PutBucketCors") + bucket.Methods(http.MethodDelete).HandlerFunc( + m.Handle(metrics.APIStats("deletebucketcors", h.DeleteBucketCorsHandler))).Queries("cors", ""). + Name("DeleteBucketCors") // Dummy Bucket Calls // GetBucketACL -- this is a dummy call. bucket.Methods(http.MethodGet).HandlerFunc( @@ -306,10 +332,6 @@ func Attach(r *mux.Router, domains []string, m MaxClients, h Handler, center aut bucket.Methods(http.MethodPut).HandlerFunc( m.Handle(metrics.APIStats("putbucketacl", h.PutBucketACLHandler))).Queries("acl", ""). Name("PutBucketACL") - // GetBucketCors - this is a dummy call. - bucket.Methods(http.MethodGet).HandlerFunc( - m.Handle(metrics.APIStats("getbucketcors", h.GetBucketCorsHandler))).Queries("cors", ""). - Name("GetBucketCors") // GetBucketWebsiteHandler - this is a dummy call. bucket.Methods(http.MethodGet).HandlerFunc( m.Handle(metrics.APIStats("getbucketwebsite", h.GetBucketWebsiteHandler))).Queries("website", ""). diff --git a/cmd/s3-gw/app.go b/cmd/s3-gw/app.go index da4cabc6c..743856918 100644 --- a/cmd/s3-gw/app.go +++ b/cmd/s3-gw/app.go @@ -6,6 +6,7 @@ import ( "math" "net" "net/http" + "strconv" "time" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" @@ -277,9 +278,10 @@ func getAccessBoxCacheConfig(v *viper.Viper, l *zap.Logger) *cache.Config { func getHandlerOptions(v *viper.Viper, l *zap.Logger) *handler.Config { var ( - cfg handler.Config - err error - policyStr = handler.DefaultPolicy + cfg handler.Config + err error + policyStr = handler.DefaultPolicy + defaultMaxAge = handler.DefaultMaxAge ) if v.IsSet(cfgDefaultPolicy) { @@ -291,5 +293,17 @@ func getHandlerOptions(v *viper.Viper, l *zap.Logger) *handler.Config { zap.Error(err)) } + if v.IsSet(cfgDefaultMaxAge) { + defaultMaxAge = v.GetInt(cfgDefaultMaxAge) + + if defaultMaxAge <= 0 && defaultMaxAge != -1 { + l.Fatal("invalid defaultMaxAge", + zap.String("parameter", cfgDefaultMaxAge), + zap.String("value in config", strconv.Itoa(defaultMaxAge))) + } + } + + cfg.DefaultMaxAge = defaultMaxAge + return &cfg } diff --git a/cmd/s3-gw/app_settings.go b/cmd/s3-gw/app_settings.go index d4630f42c..fda2f98c0 100644 --- a/cmd/s3-gw/app_settings.go +++ b/cmd/s3-gw/app_settings.go @@ -71,6 +71,9 @@ const ( // Settings. // Policy. cfgDefaultPolicy = "default_policy" + // CORS. + cfgDefaultMaxAge = "cors.default_max_age" + // MaxClients. cfgMaxClientsCount = "max_clients_count" cfgMaxClientsDeadline = "max_clients_deadline"