diff --git a/internal/bucket/cors/cors.go b/internal/bucket/cors/cors.go new file mode 100644 index 000000000..91822f13b --- /dev/null +++ b/internal/bucket/cors/cors.go @@ -0,0 +1,150 @@ +// Copyright (c) 2015-2021 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 implements the S3 per-bucket CORS configuration type, +// its validation, and origin/method/header matching helpers. +package cors + +import ( + "encoding/xml" + "errors" + "io" + "strings" + + "github.com/minio/pkg/v3/wildcard" +) + +// maxCORSRules is the maximum number of rules allowed per bucket (AWS S3 limit). +const maxCORSRules = 100 + +// supportedMethods are the HTTP methods permitted in an AllowedMethod element. +var supportedMethods = map[string]bool{ + "GET": true, + "PUT": true, + "HEAD": true, + "POST": true, + "DELETE": true, +} + +// Config is the S3 document. +type Config struct { + XMLName xml.Name `xml:"CORSConfiguration"` + CORSRules []Rule `xml:"CORSRule"` +} + +// Rule is a single . +type Rule struct { + ID string `xml:"ID,omitempty"` + AllowedHeaders []string `xml:"AllowedHeader"` + AllowedMethods []string `xml:"AllowedMethod"` + AllowedOrigins []string `xml:"AllowedOrigin"` + ExposeHeaders []string `xml:"ExposeHeader"` + MaxAgeSeconds int `xml:"MaxAgeSeconds"` +} + +// ParseBucketCorsConfig parses a CORS configuration from the given reader. +func ParseBucketCorsConfig(r io.Reader) (*Config, error) { + var c Config + if err := xml.NewDecoder(r).Decode(&c); err != nil { + return nil, err + } + return &c, nil +} + +// Validate checks the config against the S3 constraints. +func (c *Config) Validate() error { + if len(c.CORSRules) == 0 { + return errors.New("CORSConfiguration must contain at least one rule") + } + if len(c.CORSRules) > maxCORSRules { + return errors.New("CORSConfiguration exceeds the maximum number of rules") + } + for _, r := range c.CORSRules { + if len(r.AllowedOrigins) == 0 { + return errors.New("CORSRule must contain at least one AllowedOrigin") + } + if len(r.AllowedMethods) == 0 { + return errors.New("CORSRule must contain at least one AllowedMethod") + } + for _, m := range r.AllowedMethods { + if !supportedMethods[strings.ToUpper(m)] { + return errors.New("unsupported method in CORSRule: " + m) + } + } + if r.MaxAgeSeconds < 0 { + return errors.New("MaxAgeSeconds must not be negative") + } + } + return nil +} + +// HasAllowedOrigin reports whether the rule allows the given origin. +func (r Rule) HasAllowedOrigin(origin string) bool { + for _, o := range r.AllowedOrigins { + if o == "*" || wildcard.MatchSimple(o, origin) { + return true + } + } + return false +} + +// HasAllowedMethod reports whether the rule allows the given HTTP method. +func (r Rule) HasAllowedMethod(method string) bool { + for _, m := range r.AllowedMethods { + if strings.EqualFold(m, method) { + return true + } + } + return false +} + +// FilterAllowedHeaders returns the subset of reqHeaders permitted by the rule +// and whether every requested header was allowed. +func (r Rule) FilterAllowedHeaders(reqHeaders []string) ([]string, bool) { + var allowed []string + for _, h := range reqHeaders { + h = strings.TrimSpace(h) + if h == "" { + continue + } + if !r.headerAllowed(h) { + return nil, false + } + allowed = append(allowed, h) + } + return allowed, true +} + +func (r Rule) headerAllowed(header string) bool { + for _, h := range r.AllowedHeaders { + if h == "*" || wildcard.MatchSimple(strings.ToLower(h), strings.ToLower(header)) { + return true + } + } + return false +} + +// MatchRule returns the first rule whose origin and method both match. +func (c *Config) MatchRule(origin, method string) (*Rule, bool) { + for i := range c.CORSRules { + r := &c.CORSRules[i] + if r.HasAllowedOrigin(origin) && r.HasAllowedMethod(method) { + return r, true + } + } + return nil, false +} diff --git a/internal/bucket/cors/cors_test.go b/internal/bucket/cors/cors_test.go new file mode 100644 index 000000000..a83cd1cfb --- /dev/null +++ b/internal/bucket/cors/cors_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2015-2021 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 ( + "strings" + "testing" +) + +const sampleCORS = ` + + rule1 + http://www.example.com + https://*.example.org + GET + PUT + x-amz-* + ETag + 3000 + +` + +func TestParseAndValidate(t *testing.T) { + c, err := ParseBucketCorsConfig(strings.NewReader(sampleCORS)) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if err := c.Validate(); err != nil { + t.Fatalf("validate failed: %v", err) + } + if len(c.CORSRules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(c.CORSRules)) + } + if c.CORSRules[0].MaxAgeSeconds != 3000 { + t.Fatalf("MaxAgeSeconds mismatch: %d", c.CORSRules[0].MaxAgeSeconds) + } +} + +func TestValidateRejections(t *testing.T) { + cases := map[string]string{ + "bad method": `*TRACE`, + "no origin": `GET`, + "no method": `*`, + "negative age": `*GET-1`, + } + for name, doc := range cases { + c, err := ParseBucketCorsConfig(strings.NewReader(doc)) + if err != nil { + continue // parse-level rejection is acceptable + } + if err := c.Validate(); err == nil { + t.Errorf("%s: expected validation error, got nil", name) + } + } +} + +func TestMatching(t *testing.T) { + c, _ := ParseBucketCorsConfig(strings.NewReader(sampleCORS)) + rule, ok := c.MatchRule("https://api.example.org", "GET") + if !ok { + t.Fatal("expected origin+method to match") + } + if _, ok := c.MatchRule("http://evil.com", "GET"); ok { + t.Fatal("did not expect match for disallowed origin") + } + if _, ok := c.MatchRule("http://www.example.com", "DELETE"); ok { + t.Fatal("did not expect match for disallowed method") + } + allowed, ok := rule.FilterAllowedHeaders([]string{"x-amz-date", "x-amz-content-sha256"}) + if !ok || len(allowed) != 2 { + t.Fatalf("expected both headers allowed via wildcard, got %v ok=%v", allowed, ok) + } + if _, ok := rule.FilterAllowedHeaders([]string{"authorization"}); ok { + t.Fatal("did not expect authorization to be allowed") + } +}