33
44package corsx
55
6- import "strings"
6+ import (
7+ "strings"
8+
9+ "golang.org/x/net/publicsuffix"
10+ )
711
812// CheckOrigin is a function that can be used well with cors.Options.AllowOriginRequestFunc.
913// It checks whether the origin is allowed following the same behavior as github.com/rs/cors.
1014//
15+ // When legacyAllowInsecureOrigins is false (the default), wildcard patterns are
16+ // only honored when ClassifyOrigin reports them as bounded at a registrable
17+ // domain. Pass true to opt into legacy (trusting) behavior for unbounded
18+ // wildcards.
19+ //
20+ // TODO: legacyAllowInsecureOrigins grandfathers a fixed set of projects through
21+ // a time-boxed migration window (feature_flags.legacy_allow_insecure_origins).
22+ // Once those projects move to bounded wildcards and the entitlement is revoked,
23+ // drop this parameter and always enforce the boundary.
24+ //
1125// Recommended usage for hot-reloadable origins:
1226//
1327// func (p *Config) cors(ctx context.Context, prefix string) (cors.Options, bool) {
@@ -20,11 +34,11 @@ import "strings"
2034// opts.AllowOriginRequestFunc = func(r *http.Request, origin string) bool {
2135// // load the origins from the config on every request to allow hot-reloading
2236// allowedOrigins := p.GetProvider(r.Context()).Strings(prefix + ".cors.allowed_origins")
23- // return corsx.CheckOrigin(allowedOrigins, origin)
37+ // return corsx.CheckOrigin(allowedOrigins, origin, false )
2438// }
2539// return opts, enabled
2640// }
27- func CheckOrigin (allowedOrigins []string , origin string ) bool {
41+ func CheckOrigin (allowedOrigins []string , origin string , legacyAllowInsecureOrigins bool ) bool {
2842 if len (allowedOrigins ) == 0 {
2943 return true
3044 }
@@ -45,10 +59,83 @@ func CheckOrigin(allowedOrigins []string, origin string) bool {
4559 }
4660 continue
4761 }
62+ // Only honor wildcards bounded at a registrable domain unless the caller
63+ // explicitly opts into legacy insecure matching. See ClassifyOrigin.
64+ if ! legacyAllowInsecureOrigins && ClassifyOrigin (o ).IsUnsafeWildcard () {
65+ continue
66+ }
4867 // inspired by https://github.com/rs/cors/blob/066574eebbd0f5f1b6cd1154a160cc292ac1835e/utils.go#L15
4968 if len (origin ) >= len (prefix )+ len (suffix ) && strings .HasPrefix (origin , prefix ) && strings .HasSuffix (origin , suffix ) {
5069 return true
5170 }
5271 }
5372 return false
5473}
74+
75+ // OriginPattern describes a CORS origin or return-URL host pattern: whether it
76+ // uses a wildcard and, if so, whether that wildcard is safely bounded at a
77+ // registrable domain.
78+ type OriginPattern struct {
79+ // HasWildcard reports whether the pattern contains a "*".
80+ HasWildcard bool
81+
82+ // BoundedWildcard reports whether the "*" is confined to a subdomain label
83+ // and the fixed domain that follows it is a registrable domain (an eTLD+1,
84+ // e.g. "example.com" or "example.co.uk"). Every host the pattern can match
85+ // then shares that one customer-owned registrable domain, so an attacker
86+ // cannot register a matching host. Always false when HasWildcard is false.
87+ BoundedWildcard bool
88+
89+ // Base is the fixed domain that follows the wildcard label — "example.com"
90+ // for "*.example.com", "com" for "*.com". It is empty for non-wildcards and
91+ // for bare or trailing wildcards where no domain follows the "*". When
92+ // BoundedWildcard is false, Base names the offending suffix, which is the
93+ // actionable signal for reporting why a wildcard was rejected.
94+ Base string
95+ }
96+
97+ // IsUnsafeWildcard reports whether the pattern is a wildcard that is NOT bounded
98+ // at a registrable domain. Such a wildcard would match an attacker-registrable
99+ // host (e.g. "https://*foo.com" matches "https://evilfoo.com"), so it must be
100+ // rejected unless the caller explicitly opts into legacy insecure matching. This
101+ // is the dominant question at call sites that gate, drop, or reject wildcard
102+ // origins and return URLs.
103+ func (p OriginPattern ) IsUnsafeWildcard () bool {
104+ return p .HasWildcard && ! p .BoundedWildcard
105+ }
106+
107+ // ClassifyOrigin inspects a CORS origin or bare host pattern and reports whether
108+ // it is a wildcard and, if so, whether the wildcard is safely bounded at a
109+ // registrable domain. Only the text from the last "*" onward is inspected, so
110+ // the result is identical whether pattern carries a scheme or is a bare host; a
111+ // trailing ":port" is ignored. Examples:
112+ //
113+ // - "https://*.example.com" → {HasWildcard: true, BoundedWildcard: true, Base: "example.com"}
114+ // - "https://*foo.com" → {HasWildcard: true, Base: "com"} (dot-less; base is a public suffix)
115+ // - "https://*.com" → {HasWildcard: true, Base: "com"} (public suffix, not registrable)
116+ // - "https://www.ory.*" → {HasWildcard: true} (trailing; no domain follows)
117+ // - "https://exact.foo.com" → {} (no wildcard)
118+ func ClassifyOrigin (pattern string ) OriginPattern {
119+ i := strings .LastIndexByte (pattern , '*' )
120+ if i < 0 {
121+ return OriginPattern {}
122+ }
123+ p := OriginPattern {HasWildcard : true }
124+ // The fixed domain is everything after the first "." that follows the last
125+ // "*" — i.e. the label containing the wildcard is dropped. Without such a dot
126+ // the wildcard is trailing or bare, so no registrable domain follows.
127+ _ , base , found := strings .Cut (pattern [i :], "." )
128+ if ! found {
129+ return p
130+ }
131+ base = strings .ToLower (base )
132+ if host , _ , found := strings .Cut (base , ":" ); found {
133+ base = host // Drop a trailing ":port".
134+ }
135+ p .Base = base
136+ // EffectiveTLDPlusOne returns an error when base is itself a public suffix
137+ // (e.g. "com", "co.uk", "vercel.app") or otherwise has no registrable domain.
138+ _ , err := publicsuffix .EffectiveTLDPlusOne (base )
139+ p .BoundedWildcard = err == nil
140+ return p
141+ }
0 commit comments