|
| 1 | +// Package cors renders ingot's cors_allowed_origins into the S3 CORS |
| 2 | +// configuration the listener reports for every bucket. |
| 3 | +// |
| 4 | +// versitygw drives all of its CORS behaviour off the backend's |
| 5 | +// GetBucketCors — ApplyBucketCORS is attached to every bucket/object |
| 6 | +// route and ctrl.CORSOptions answers preflights from the same document |
| 7 | +// (s3api/router.go, s3api/controllers/options.go) — so ingot's whole job |
| 8 | +// is producing a valid configuration. s3frontend marshals and serves it; |
| 9 | +// nothing here matches origins or touches a request. |
| 10 | +package cors |
| 11 | + |
| 12 | +import ( |
| 13 | + "fmt" |
| 14 | + "net/http" |
| 15 | + "strings" |
| 16 | + |
| 17 | + "github.com/fil-forge/versitygw/auth" |
| 18 | +) |
| 19 | + |
| 20 | +// allowedMethods are the S3 verbs the rule permits. These are exactly |
| 21 | +// the methods auth.CORSHTTPMethod.IsValid accepts. |
| 22 | +var allowedMethods = []auth.CORSHTTPMethod{ |
| 23 | + http.MethodGet, |
| 24 | + http.MethodHead, |
| 25 | + http.MethodPut, |
| 26 | + http.MethodPost, |
| 27 | + http.MethodDelete, |
| 28 | +} |
| 29 | + |
| 30 | +// exposeHeaders are the response headers browser JavaScript may read on a |
| 31 | +// cross-origin response. ETag is the one S3 clients can't live without |
| 32 | +// (PUT/multipart verification) and is listed explicitly because the |
| 33 | +// preflight controller doesn't apply versitygw's ensureExposeETag |
| 34 | +// fallback; the x-amz-* set covers request tracing and versioning. |
| 35 | +var exposeHeaders = []auth.CORSHeader{ |
| 36 | + "ETag", |
| 37 | + "x-amz-storage-class", |
| 38 | + "x-amz-request-id", |
| 39 | + "x-amz-id-2", |
| 40 | + "x-amz-version-id", |
| 41 | +} |
| 42 | + |
| 43 | +// maxAgeSeconds caps how long a browser may cache a preflight result. |
| 44 | +// Without it browsers fall back to ~5s and re-preflight almost every |
| 45 | +// request — an extra round trip per PUT for a browser client. |
| 46 | +const maxAgeSeconds int32 = 600 |
| 47 | + |
| 48 | +// Build renders origins as a single-rule S3 CORS configuration. An empty |
| 49 | +// list yields (nil, nil): CORS disabled, which s3frontend reports as |
| 50 | +// NoSuchCORSConfiguration so versitygw's CORS middlewares fall through |
| 51 | +// untouched. |
| 52 | +// |
| 53 | +// Origins are matched by versitygw at request time with S3 semantics |
| 54 | +// (auth.wildcardMatch): an exact origin, or one '*' standing for any run |
| 55 | +// of characters ("https://*.dev.example"). Matching is over the raw |
| 56 | +// Origin header, so a non-default port must be spelled out. |
| 57 | +func Build(origins []string) (*auth.CORSConfiguration, error) { |
| 58 | + if len(origins) == 0 { |
| 59 | + return nil, nil |
| 60 | + } |
| 61 | + |
| 62 | + allowed := make([]auth.CORSOrigin, 0, len(origins)) |
| 63 | + for _, raw := range origins { |
| 64 | + o := strings.ToLower(strings.TrimSpace(raw)) |
| 65 | + if err := validateOrigin(raw, o); err != nil { |
| 66 | + return nil, err |
| 67 | + } |
| 68 | + allowed = append(allowed, auth.CORSOrigin(o)) |
| 69 | + } |
| 70 | + |
| 71 | + maxAge := maxAgeSeconds |
| 72 | + cfg := &auth.CORSConfiguration{ |
| 73 | + Rules: []auth.CORSRule{{ |
| 74 | + AllowedOrigins: allowed, |
| 75 | + AllowedMethods: allowedMethods, |
| 76 | + // Every requested header must match an entry or |
| 77 | + // CORSRule.Match rejects the preflight, and S3 clients send |
| 78 | + // an open-ended x-amz-* set alongside authorization. |
| 79 | + AllowedHeaders: []auth.CORSHeader{"*"}, |
| 80 | + ExposeHeaders: exposeHeaders, |
| 81 | + MaxAgeSeconds: &maxAge, |
| 82 | + }}, |
| 83 | + } |
| 84 | + if err := cfg.Validate(); err != nil { |
| 85 | + return nil, fmt.Errorf("cors: %w", err) |
| 86 | + } |
| 87 | + return cfg, nil |
| 88 | +} |
| 89 | + |
| 90 | +// validateOrigin rejects anything that couldn't be a browser Origin. |
| 91 | +// versitygw's own CORSOrigin.Validate only rejects a second '*', so |
| 92 | +// without this a typo like "app.example" would be accepted and then |
| 93 | +// silently never match; raw is carried through for the error message. |
| 94 | +func validateOrigin(raw, o string) error { |
| 95 | + if o == "" { |
| 96 | + return fmt.Errorf("cors: empty origin") |
| 97 | + } |
| 98 | + if o == "*" { |
| 99 | + return nil |
| 100 | + } |
| 101 | + rest, ok := strings.CutPrefix(o, "https://") |
| 102 | + if !ok { |
| 103 | + rest, ok = strings.CutPrefix(o, "http://") |
| 104 | + } |
| 105 | + if !ok { |
| 106 | + return fmt.Errorf("cors: origin %q must start with http:// or https://", raw) |
| 107 | + } |
| 108 | + // '@' rejects userinfo (https://user@host): an Origin header is only |
| 109 | + // scheme+host(+port), so such an entry could never match a request. |
| 110 | + if rest == "" || strings.ContainsAny(rest, "/?#@") { |
| 111 | + return fmt.Errorf("cors: origin %q must be a bare origin (scheme://host[:port], no path or userinfo)", raw) |
| 112 | + } |
| 113 | + if strings.Count(o, "*") > 1 { |
| 114 | + return fmt.Errorf("cors: origin %q: at most one '*' is allowed", raw) |
| 115 | + } |
| 116 | + return nil |
| 117 | +} |
0 commit comments