diff --git a/cors/cors.go b/cors/cors.go new file mode 100644 index 0000000..f2f45b9 --- /dev/null +++ b/cors/cors.go @@ -0,0 +1,179 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// # This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cors + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + + "github.com/minio/pkg/v3/wildcard" +) + +const defaultXMLNS = "http://s3.amazonaws.com/doc/2006-03-01/" + +var allowedCORSRuleMethods = map[string]bool{ + http.MethodGet: true, + http.MethodPut: true, + http.MethodPost: true, + http.MethodDelete: true, + http.MethodHead: true, +} + +// Config is the container for a CORS configuration for a bucket. +type Config struct { + XMLNS string `xml:"xmlns,attr,omitempty"` + XMLName xml.Name `xml:"CORSConfiguration"` + CORSRules []Rule `xml:"CORSRule"` +} + +// Rule is a single rule in a CORS configuration. +type Rule struct { + AllowedHeader []string `xml:"AllowedHeader,omitempty"` + AllowedMethod []string `xml:"AllowedMethod,omitempty"` + AllowedOrigin []string `xml:"AllowedOrigin,omitempty"` + ExposeHeader []string `xml:"ExposeHeader,omitempty"` + ID string `xml:"ID,omitempty"` + MaxAgeSeconds int `xml:"MaxAgeSeconds,omitempty"` +} + +// Validate checks the CORS configuration is valid. This has been implemented to return errors that can be transformed +// to match the S3 API externally, while being slightly more informative internally using wrapping. +// Validate copies S3 behavior, and validates one rule at a time, erroring on the first invalid one found. +func (c *Config) Validate() error { + if len(c.CORSRules) == 0 { + return fmt.Errorf("no CORS rules found, %w", ErrMalformedXML{}) + } + if len(c.CORSRules) > 100 { + return fmt.Errorf("too many CORS rules, max 100 allowed, got: %d, %w", len(c.CORSRules), ErrTooManyRules{}) + } + for _, rule := range c.CORSRules { + // Origin validation + if len(rule.AllowedOrigin) == 0 { + return fmt.Errorf("no AllowedOrigin found in CORS rule, id: %s, %w", rule.ID, ErrMalformedXML{}) + } + for _, origin := range rule.AllowedOrigin { + if strings.Count(origin, "*") > 1 { + return fmt.Errorf("origin %s in CORS rule, id: %s, %w", origin, rule.ID, ErrAllowedOriginWildcards{Origin: origin}) + } + } + + // Methods validation + if len(rule.AllowedMethod) == 0 { + return fmt.Errorf("no AllowedMethod found in CORS rule, id: %s, %w", rule.ID, ErrMalformedXML{}) + } + for _, method := range rule.AllowedMethod { + if !allowedCORSRuleMethods[method] { + return fmt.Errorf("method %s in CORS rule, id: %s, %w", method, rule.ID, ErrInvalidMethod{Method: method}) + } + } + + // Headers validation + for _, header := range rule.AllowedHeader { + if strings.Count(header, "*") > 1 { + return fmt.Errorf("header %s in CORS rule, id: %s, %w", header, rule.ID, ErrAllowedHeaderWildcards{Header: header}) + } + } + } + + return nil +} + +// HasAllowedOrigin returns true if the given origin is allowed by the CORS rule +func (c *Rule) HasAllowedOrigin(origin string) bool { + // See "AllowedOrigin element" in https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html + for _, allowedOrigin := range c.AllowedOrigin { + if wildcard.Match(allowedOrigin, origin) { + // Only one wildcard character (*) is allowed by S3 spec, but Match does + // not enforce that, it's done by Validate() function. + // Origins are case sensitive + return true + } + } + return false +} + +// HasAllowedMethod returns true if the given method is contained in the CORS rule. +func (c *Rule) HasAllowedMethod(method string) bool { + // See "AllowedMethod element" in https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html + for _, allowedMethod := range c.AllowedMethod { + if allowedMethod == method { + // Methods are always uppercase, enforced by Validate() function. + return true + } + } + return false +} + +// FilterAllowedHeaders returns the headers that are allowed by the rule, and a boolean indicating if all headers are allowed. +func (c *Rule) FilterAllowedHeaders(headers []string) ([]string, bool) { + // See "AllowedHeader element" in https://docs.aws.amazon.com/AmazonS3/latest/userguide/ManageCorsUsing.html + // It's inefficient to store the CORS config verbatim and run ToLower here, but S3 essentially + // behaves this way, and will return the XML config verbatim when you GET it. + filtered := []string{} + for _, header := range headers { + header = strings.ToLower(header) + found := false + for _, allowedHeader := range c.AllowedHeader { + // Case insensitive comparison for headers + if wildcard.Match(strings.ToLower(allowedHeader), header) { + // Only one wildcard character (*) is allowed by S3 spec, but Match does + // not enforce that, it's done by rule.Validate() function. + filtered = append(filtered, header) + found = true + break + } + } + if !found { + return nil, false + } + } + return filtered, true +} + +// ParseBucketCorsConfig parses a CORS configuration in XML from an io.Reader. +func ParseBucketCorsConfig(reader io.Reader) (*Config, error) { + var c Config + err := xml.NewDecoder(reader).Decode(&c) + if err != nil { + return nil, fmt.Errorf("decoding xml: %w", err) + } + if c.XMLNS == "" { + c.XMLNS = defaultXMLNS + } + for i, rule := range c.CORSRules { + for j, method := range rule.AllowedMethod { + c.CORSRules[i].AllowedMethod[j] = strings.ToUpper(method) + } + } + return &c, nil +} + +// ToXML marshals the CORS configuration to XML. +func (c Config) ToXML() ([]byte, error) { + if c.XMLNS == "" { + c.XMLNS = defaultXMLNS + } + data, err := xml.Marshal(&c) + if err != nil { + return nil, fmt.Errorf("marshaling xml: %w", err) + } + return append([]byte(xml.Header), data...), nil +} diff --git a/cors/cors_test.go b/cors/cors_test.go new file mode 100644 index 0000000..b4757c8 --- /dev/null +++ b/cors/cors_test.go @@ -0,0 +1,290 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// # This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cors + +import ( + "bytes" + "encoding/xml" + "os" + "reflect" + "strings" + "testing" +) + +var defaultXMLName = xml.Name{Space: "http://s3.amazonaws.com/doc/2006-03-01/", Local: "CORSConfiguration"} + +func TestCORSFilterHeaders(t *testing.T) { + tests := []struct { + name string + rule Rule + headers []string + + wantOk bool + wantHeaders []string + }{ + { + name: "plain single header", + rule: Rule{AllowedHeader: []string{"x-custom-header"}}, + headers: []string{"x-custom-header"}, + wantOk: true, + wantHeaders: []string{"x-custom-header"}, + }, + { + name: "single header case insensitive", + rule: Rule{AllowedHeader: []string{"x-CUSTOM-header"}}, + headers: []string{"x-custom-HEADER"}, + wantOk: true, + wantHeaders: []string{"x-custom-header"}, + }, + { + name: "plain multiple headers in order", + rule: Rule{AllowedHeader: []string{"x-custom-header-1", "x-custom-header-2"}}, + headers: []string{"x-custom-header-1", "x-custom-header-2"}, + wantOk: true, + wantHeaders: []string{"x-custom-header-1", "x-custom-header-2"}, + }, + { + name: "plain multiple headers out of order", + rule: Rule{AllowedHeader: []string{"x-custom-header-2", "x-custom-header-1"}}, + headers: []string{"x-custom-header-1", "x-custom-header-2"}, + wantOk: true, + wantHeaders: []string{"x-custom-header-1", "x-custom-header-2"}, + }, + { + name: "plain multiple headers with unknown header", + rule: Rule{AllowedHeader: []string{"x-custom-header-1", "x-custom-header-2"}}, + headers: []string{"x-custom-header-1", "x-custom-header-2", "x-custom-header-3"}, + wantOk: false, + wantHeaders: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := &Config{ + CORSRules: []Rule{test.rule}, + } + for _, rule := range config.CORSRules { + headers, ok := rule.FilterAllowedHeaders(test.headers) + if ok != test.wantOk { + t.Errorf("got: %v, want: %v", ok, test.wantOk) + } + if !reflect.DeepEqual(headers, test.wantHeaders) { + t.Errorf("got: %v, want: %v", headers, test.wantHeaders) + } + } + }) + } +} + +func TestCORSInvalid(t *testing.T) { + tests := []struct { + name string + config *Config + wantErrContains string + }{ + { + name: "no CORS rules", + config: &Config{ + CORSRules: []Rule{}, + }, + wantErrContains: "no CORS rules found", + }, + { + name: "too many CORS rules", + config: &Config{ + CORSRules: make([]Rule, 101), + }, + wantErrContains: "too many CORS rules", + }, + { + name: "no AllowedOrigin", + config: &Config{ + CORSRules: []Rule{ + { + ID: "1", + AllowedOrigin: []string{}, + AllowedMethod: []string{"GET"}, + }, + }, + }, + wantErrContains: "no AllowedOrigin found in CORS rule, id: 1", + }, + { + name: "invalid origin multiple wildcards", + config: &Config{ + CORSRules: []Rule{ + { + AllowedOrigin: []string{"https", "http://*.example.*"}, + AllowedMethod: []string{"GET"}, + }, + }, + }, + wantErrContains: "can not have more than one wildcard", + }, + { + name: "no AllowedMethod", + config: &Config{ + CORSRules: []Rule{ + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{}, + }, + }, + }, + wantErrContains: "no AllowedMethod found in CORS rule", + }, + { + name: "invalid method", + config: &Config{ + CORSRules: []Rule{ + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{"GET", "POST", "PATCH"}, + }, + }, + }, + wantErrContains: "Unsupported method is PATCH", + }, + { + name: "invalid method lowercase", + config: &Config{ + CORSRules: []Rule{ + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{"get"}, + }, + }, + }, + wantErrContains: "Unsupported method is get", + }, + { + name: "invalid header multiple wildcards", + config: &Config{ + CORSRules: []Rule{ + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{"GET"}, + AllowedHeader: []string{"X-*-Header-*"}, + }, + }, + }, + wantErrContains: "not have more than one wildcard", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.config.Validate() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), test.wantErrContains) { + t.Errorf("got: %v, want contains: %v", err, test.wantErrContains) + } + }) + } +} + +func TestCORSXMLValid(t *testing.T) { + tests := []struct { + name string + filename string + wantCORSConfig *Config + }{ + { + name: "example1 cors config", + filename: "example1.xml", + wantCORSConfig: &Config{ + XMLName: defaultXMLName, + XMLNS: defaultXMLNS, + CORSRules: []Rule{ + { + AllowedOrigin: []string{"http://www.example1.com"}, + AllowedMethod: []string{"PUT", "POST", "DELETE"}, + AllowedHeader: []string{"*"}, + }, + { + AllowedOrigin: []string{"http://www.example2.com"}, + AllowedMethod: []string{"PUT", "POST", "DELETE"}, + AllowedHeader: []string{"*"}, + }, + { + AllowedOrigin: []string{"*"}, + AllowedMethod: []string{"GET"}, + }, + }, + }, + }, + { + name: "example2 cors config", + filename: "example2.xml", + wantCORSConfig: &Config{ + XMLName: defaultXMLName, + XMLNS: defaultXMLNS, + CORSRules: []Rule{ + { + AllowedOrigin: []string{"http://www.example.com"}, + AllowedMethod: []string{"PUT", "POST", "DELETE"}, + AllowedHeader: []string{"*"}, + MaxAgeSeconds: 3000, + ExposeHeader: []string{"x-amz-server-side-encryption", "x-amz-request-id", "x-amz-id-2"}, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fileContents, err := os.ReadFile("testdata/" + test.filename) + if err != nil { + t.Fatal(err) + } + c, err := ParseBucketCorsConfig(bytes.NewReader(fileContents)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(c, test.wantCORSConfig) { + t.Errorf("got: %v, want: %v", c, test.wantCORSConfig) + } + err = c.Validate() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestCORSXMLMarshal(t *testing.T) { + fileContents, err := os.ReadFile("testdata/example3.xml") + if err != nil { + t.Fatal(err) + } + c, err := ParseBucketCorsConfig(bytes.NewReader(fileContents)) + if err != nil { + t.Fatal(err) + } + remarshalled, err := c.ToXML() + if err != nil { + t.Fatal(err) + } + trimmedFileContents := bytes.TrimSpace(fileContents) + if !bytes.Equal(trimmedFileContents, remarshalled) { + t.Errorf("got: %s, want: %s", string(remarshalled), string(trimmedFileContents)) + } +} diff --git a/cors/errors.go b/cors/errors.go new file mode 100644 index 0000000..45c7044 --- /dev/null +++ b/cors/errors.go @@ -0,0 +1,65 @@ +// Copyright (c) 2015-2024 MinIO, Inc. +// +// # This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cors + +import "fmt" + +// ErrTooManyRules is returned when the number of CORS rules exceeds the allowed limit. +type ErrTooManyRules struct{} + +func (e ErrTooManyRules) Error() string { + return "The number of CORS rules should not exceed allowed limit of 100 rules." +} + +// ErrMalformedXML is returned when the XML provided is not well-formed +type ErrMalformedXML struct{} + +func (e ErrMalformedXML) Error() string { + return "The XML you provided was not well-formed or did not validate against our published schema" +} + +// ErrAllowedOriginWildcards is returned when more than one wildcard is found in an AllowedOrigin. +type ErrAllowedOriginWildcards struct { + Origin string +} + +func (e ErrAllowedOriginWildcards) Error() string { + // S3 quotes the origin, e.g. "http://*.*.example.com", in the error message, but these quotes are currently + // escaped by Go xml encoder. We could fix this with a `,innerxml` tag on the struct, but that has + // other implications. Easier to not add quotes in our error for now, revisit if this is an issue. + return fmt.Sprintf(`AllowedOrigin %s can not have more than one wildcard.`, e.Origin) +} + +// ErrInvalidMethod is returned when an unsupported HTTP method is found in a CORS config. +type ErrInvalidMethod struct { + Method string +} + +func (e ErrInvalidMethod) Error() string { + return fmt.Sprintf("Found unsupported HTTP method in CORS config. Unsupported method is %s", e.Method) +} + +// ErrAllowedHeaderWildcards is returned when more than one wildcard is found in an AllowedHeader. +type ErrAllowedHeaderWildcards struct { + Header string +} + +func (e ErrAllowedHeaderWildcards) Error() string { + // S3 quotes the header, e.g. "*-amz-*", in the error message, similar situation to ErrAllowedOriginWildcards above. + return fmt.Sprintf(`AllowedHeader %s can not have more than one wildcard.`, e.Header) +} diff --git a/cors/testdata/example1.xml b/cors/testdata/example1.xml new file mode 100644 index 0000000..aa78aee --- /dev/null +++ b/cors/testdata/example1.xml @@ -0,0 +1,21 @@ + + + + http://www.example1.com + PUT + POST + DELETE + * + + + http://www.example2.com + PUT + POST + DELETE + * + + + * + GET + + diff --git a/cors/testdata/example2.xml b/cors/testdata/example2.xml new file mode 100644 index 0000000..f7b8aca --- /dev/null +++ b/cors/testdata/example2.xml @@ -0,0 +1,14 @@ + + + + http://www.example.com + PUT + POST + DELETE + * + 3000 + x-amz-server-side-encryption + x-amz-request-id + x-amz-id-2 + + diff --git a/cors/testdata/example3.xml b/cors/testdata/example3.xml new file mode 100644 index 0000000..fb2f33a --- /dev/null +++ b/cors/testdata/example3.xml @@ -0,0 +1,2 @@ + +*PUTPOSTDELETEhttp://www.example1.com*PUTPOSTDELETEhttp://www.example2.*GET*x-amz-id-26000POSThttps://www.example3.com diff --git a/go.mod b/go.mod index ada24d1..fd678b2 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/minio/pkg/v3 go 1.21 require ( - github.com/cespare/xxhash/v2 v2.2.0 github.com/cheggaaa/pb v1.0.29 github.com/fatih/color v1.16.0 github.com/fatih/structs v1.1.0 @@ -16,7 +15,6 @@ require ( github.com/minio/mux v1.8.2 github.com/montanaflynn/stats v0.7.1 github.com/rjeczalik/notify v0.9.3 - github.com/secure-io/sio-go v0.3.1 github.com/tinylib/msgp v1.2.0 go.etcd.io/etcd/client/v3 v3.5.13 golang.org/x/crypto v0.24.0 @@ -58,6 +56,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.5.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect + github.com/secure-io/sio-go v0.3.1 // indirect github.com/shirou/gopsutil/v3 v3.24.4 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.14 // indirect diff --git a/go.sum b/go.sum index 58711fc..f552c39 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= diff --git a/policy/action.go b/policy/action.go index 40d885c..5f236f2 100644 --- a/policy/action.go +++ b/policy/action.go @@ -44,6 +44,9 @@ const ( // DeleteBucketPolicyAction - DeleteBucketPolicy Rest API action. DeleteBucketPolicyAction = "s3:DeleteBucketPolicy" + // DeleteBucketCorsAction - DeleteBucketCors Rest API action. + DeleteBucketCorsAction = "s3:DeleteBucketCors" + // DeleteObjectAction - DeleteObject Rest API action. DeleteObjectAction = "s3:DeleteObject" @@ -56,6 +59,9 @@ const ( // GetBucketPolicyAction - GetBucketPolicy Rest API action. GetBucketPolicyAction = "s3:GetBucketPolicy" + // GetBucketCorsAction - GetBucketCors Rest API action. + GetBucketCorsAction = "s3:GetBucketCors" + // GetObjectAction - GetObject Rest API action. GetObjectAction = "s3:GetObject" @@ -103,6 +109,9 @@ const ( // PutBucketPolicyAction - PutBucketPolicy Rest API action. PutBucketPolicyAction = "s3:PutBucketPolicy" + // PutBucketCorsAction - PutBucketCors Rest API action. + PutBucketCorsAction = "s3:PutBucketCors" + // PutObjectAction - PutObject Rest API action. PutObjectAction = "s3:PutObject"