feat: add internal/bucket/cors CORS config type and matching

Signed-off-by: h5vx <h5v@protonmail.com>
This commit is contained in:
h5vx
2026-08-25 16:50:30 +05:00
parent f2520f3346
commit 1c9a2431fe
2 changed files with 241 additions and 0 deletions
+150
View File
@@ -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 <http://www.gnu.org/licenses/>.
// 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 <CORSConfiguration> document.
type Config struct {
XMLName xml.Name `xml:"CORSConfiguration"`
CORSRules []Rule `xml:"CORSRule"`
}
// Rule is a single <CORSRule>.
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
}
+91
View File
@@ -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 <http://www.gnu.org/licenses/>.
package cors
import (
"strings"
"testing"
)
const sampleCORS = `<CORSConfiguration>
<CORSRule>
<ID>rule1</ID>
<AllowedOrigin>http://www.example.com</AllowedOrigin>
<AllowedOrigin>https://*.example.org</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedHeader>x-amz-*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>3000</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>`
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": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>TRACE</AllowedMethod></CORSRule></CORSConfiguration>`,
"no origin": `<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>`,
"no method": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin></CORSRule></CORSConfiguration>`,
"negative age": `<CORSConfiguration><CORSRule><AllowedOrigin>*</AllowedOrigin><AllowedMethod>GET</AllowedMethod><MaxAgeSeconds>-1</MaxAgeSeconds></CORSRule></CORSConfiguration>`,
}
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")
}
}