- with supports_credentials, a * origin must be the whole leftmost host label, patterns must be anchored with ^ and $, and no origin rule may match the probe origins outside any configured domain - globally and per profile; non-credentialed configs load as before
546 lines
18 KiB
Go
546 lines
18 KiB
Go
package surf
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"regexp/syntax"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
)
|
|
|
|
// CORSConfig matches Laravel config/cors.php keys.
|
|
type CORSConfig struct {
|
|
Paths []string `koanf:"paths"`
|
|
AllowedMethods []string `koanf:"allowed_methods"`
|
|
AllowedOrigins []string `koanf:"allowed_origins"`
|
|
AllowedOriginsPatterns []string `koanf:"allowed_origins_patterns"`
|
|
AllowedHeaders []string `koanf:"allowed_headers"`
|
|
ExposedHeaders []string `koanf:"exposed_headers"`
|
|
MaxAge int `koanf:"max_age"`
|
|
SupportsCredentials bool `koanf:"supports_credentials"`
|
|
}
|
|
|
|
// LoadCORSConfig reads http.cors. Missing section yields a zero config (no
|
|
// path matches, so no CORS headers). The config is validated: credentials
|
|
// with a "*" origin, an origin pattern that does not compile and a pattern
|
|
// still wrapped in PHP delimiters (#^...$#) are errors naming the key. With
|
|
// credentials, a wildcard origin must be the whole leftmost host label
|
|
// (https://*.example.com), a pattern must be anchored with ^ and $, and no
|
|
// origin rule may match an origin outside the configured domains.
|
|
func LoadCORSConfig(cfg *compass.Config) (CORSConfig, error) {
|
|
var out CORSConfig
|
|
if cfg == nil || !cfg.Has("http.cors") {
|
|
return out, nil
|
|
}
|
|
if err := cfg.LoadSection("http.cors", &out); err != nil {
|
|
return out, err
|
|
}
|
|
if err := validateCORSConfig("http.cors", out); err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// validateCORSConfig checks a CORS block; prefix is its config path
|
|
// (http.cors or http.cors_profiles.<name>) for the error messages.
|
|
func validateCORSConfig(prefix string, cfg CORSConfig) error {
|
|
if cfg.SupportsCredentials && containsStar(cfg.AllowedOrigins) {
|
|
return fmt.Errorf("surf: %s.supports_credentials is true with \"*\" in %s.allowed_origins: a credentialed answer would trust every origin; list the exact origins (or use %s.allowed_origins_patterns)", prefix, prefix, prefix)
|
|
}
|
|
for i, p := range cfg.AllowedOriginsPatterns {
|
|
if phpDelimited(p) {
|
|
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q is wrapped in PHP delimiters; drop the leading and trailing %q (and any flags): Go patterns take none", prefix, i, p, p[:1])
|
|
}
|
|
re, err := regexp.Compile(p)
|
|
if err != nil {
|
|
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q does not compile: %v", prefix, i, p, err)
|
|
}
|
|
if !cfg.SupportsCredentials {
|
|
continue
|
|
}
|
|
if !anchoredOriginPattern(p) {
|
|
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q is not anchored: with %s.supports_credentials true a pattern must start with ^ and end with $, as in ^https://[a-z]+\\.example\\.com$, or it matches inside any origin", prefix, i, p, prefix)
|
|
}
|
|
if probe, ok := matchesCORSProbe(re); ok {
|
|
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q matches %s, an origin outside any configured domain: with %s.supports_credentials true a pattern must name your own domains", prefix, i, p, probe, prefix)
|
|
}
|
|
}
|
|
if !cfg.SupportsCredentials {
|
|
return nil
|
|
}
|
|
for i, o := range cfg.AllowedOrigins {
|
|
if o == "*" || !strings.Contains(o, "*") {
|
|
continue
|
|
}
|
|
if !wildcardHostLabel(o) {
|
|
return fmt.Errorf("surf: %s.allowed_origins[%d] %q: with %s.supports_credentials true a wildcard must be the whole leftmost host label after a literal scheme, as in https://*.example.com", prefix, i, o, prefix)
|
|
}
|
|
if probe, ok := matchesCORSProbe(regexp.MustCompile(wildcardOriginPattern(o))); ok {
|
|
return fmt.Errorf("surf: %s.allowed_origins[%d] %q matches %s, an origin outside any configured domain: with %s.supports_credentials true a wildcard must sit in front of your own domain, as in https://*.example.com", prefix, i, o, probe, prefix)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// corsProbeOrigins are origins no credentialed origin rule may match: a
|
|
// rule that matches them trusts origins outside the configured domains
|
|
// (https://*, https://*.com, ^https?://.*$).
|
|
var corsProbeOrigins = []string{
|
|
"https://cors-probe.invalid",
|
|
"http://cors-probe.invalid",
|
|
"https://cors-probe.com",
|
|
"null",
|
|
}
|
|
|
|
// matchesCORSProbe reports the first probe origin re matches.
|
|
func matchesCORSProbe(re *regexp.Regexp) (string, bool) {
|
|
for _, probe := range corsProbeOrigins {
|
|
if re.MatchString(probe) {
|
|
return probe, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// wildcardHostLabel reports an allowed_origins entry whose only "*" is the
|
|
// whole leftmost host label after a literal scheme: https://*.example.com.
|
|
func wildcardHostLabel(origin string) bool {
|
|
scheme, host, ok := strings.Cut(origin, "://")
|
|
if !ok || scheme == "" || strings.Contains(scheme, "*") {
|
|
return false
|
|
}
|
|
rest, ok := strings.CutPrefix(host, "*.")
|
|
return ok && rest != "" && !strings.Contains(rest, "*")
|
|
}
|
|
|
|
// anchoredOriginPattern reports a pattern anchored at both ends: it starts
|
|
// with ^ and ends with $, and every alternative of the parsed expression
|
|
// stays anchored, so ^a|b$ is refused.
|
|
func anchoredOriginPattern(p string) bool {
|
|
if !strings.HasPrefix(p, "^") || !strings.HasSuffix(p, "$") {
|
|
return false
|
|
}
|
|
re, err := syntax.Parse(p, syntax.Perl)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return anchoredAt(re, true) && anchoredAt(re, false)
|
|
}
|
|
|
|
// anchoredAt reports whether every match of re begins (start true) or ends
|
|
// at the text boundary.
|
|
func anchoredAt(re *syntax.Regexp, start bool) bool {
|
|
switch re.Op {
|
|
case syntax.OpBeginText:
|
|
return start
|
|
case syntax.OpEndText:
|
|
return !start
|
|
case syntax.OpCapture:
|
|
return anchoredAt(re.Sub[0], start)
|
|
case syntax.OpConcat:
|
|
if len(re.Sub) == 0 {
|
|
return false
|
|
}
|
|
if start {
|
|
return anchoredAt(re.Sub[0], start)
|
|
}
|
|
return anchoredAt(re.Sub[len(re.Sub)-1], start)
|
|
case syntax.OpAlternate:
|
|
for _, sub := range re.Sub {
|
|
if !anchoredAt(sub, start) {
|
|
return false
|
|
}
|
|
}
|
|
return len(re.Sub) > 0
|
|
}
|
|
return false
|
|
}
|
|
|
|
// phpDelimited reports a preg pattern such as #^https://x$# or /^x$/i: the
|
|
// first byte is # or /, the same byte closes the pattern and only flag
|
|
// letters follow it. An origin pattern never starts with either byte.
|
|
func phpDelimited(p string) bool {
|
|
if len(p) < 2 || (p[0] != '#' && p[0] != '/') {
|
|
return false
|
|
}
|
|
end := strings.LastIndexByte(p, p[0])
|
|
if end <= 0 {
|
|
return false
|
|
}
|
|
for _, c := range p[end+1:] {
|
|
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// wildcardOriginPattern turns an allowed_origins entry containing "*" into an
|
|
// anchored pattern, as php-cors convertWildcardToPattern does: the rest is
|
|
// quoted and each "*" matches any characters.
|
|
func wildcardOriginPattern(origin string) string {
|
|
parts := strings.Split(origin, "*")
|
|
for i, part := range parts {
|
|
parts[i] = regexp.QuoteMeta(part)
|
|
}
|
|
return "^" + strings.Join(parts, ".*") + "$"
|
|
}
|
|
|
|
// corsPolicy is the per-request CORS logic shared by the global path-scoped
|
|
// wrapper and the cors:<profile> middleware.
|
|
type corsPolicy struct {
|
|
cfg CORSConfig
|
|
origins map[string]struct{}
|
|
originPats []*regexp.Regexp
|
|
allowAnyOrigin bool
|
|
allowAnyMethod bool
|
|
allowAnyHeader bool
|
|
// dynamic is true when the Allow-Origin answer depends on the request
|
|
// Origin (credentials, patterns or several origins): every response then
|
|
// carries Vary: Origin, so a shared cache never serves one origin's
|
|
// answer to another.
|
|
dynamic bool
|
|
methods string
|
|
headers string
|
|
exposed string
|
|
}
|
|
|
|
func newCORSPolicy(cfg CORSConfig) *corsPolicy {
|
|
p := &corsPolicy{
|
|
cfg: cfg,
|
|
origins: make(map[string]struct{}, len(cfg.AllowedOrigins)),
|
|
allowAnyOrigin: containsStar(cfg.AllowedOrigins),
|
|
allowAnyMethod: containsStar(cfg.AllowedMethods),
|
|
allowAnyHeader: containsStar(cfg.AllowedHeaders),
|
|
methods: strings.Join(cfg.AllowedMethods, ", "),
|
|
headers: strings.Join(cfg.AllowedHeaders, ", "),
|
|
exposed: strings.Join(cfg.ExposedHeaders, ", "),
|
|
}
|
|
for _, pat := range cfg.AllowedOriginsPatterns {
|
|
re, err := regexp.Compile(pat)
|
|
if err != nil {
|
|
continue // LoadCORSConfig refuses it; a hand-built config skips it
|
|
}
|
|
p.originPats = append(p.originPats, re)
|
|
}
|
|
for _, o := range cfg.AllowedOrigins {
|
|
switch {
|
|
case o == "" || o == "*":
|
|
case strings.Contains(o, "*"):
|
|
if !p.allowAnyOrigin {
|
|
p.originPats = append(p.originPats, regexp.MustCompile(wildcardOriginPattern(o)))
|
|
}
|
|
default:
|
|
p.origins[o] = struct{}{}
|
|
}
|
|
}
|
|
p.dynamic = cfg.SupportsCredentials || len(p.originPats) > 0 || len(p.origins) > 1
|
|
return p
|
|
}
|
|
|
|
// apply sets the CORS response headers for origin and reports whether the
|
|
// origin is allowed.
|
|
func (p *corsPolicy) apply(h http.Header, origin string) bool {
|
|
allowed, value := corsAllowOrigin(p.allowAnyOrigin, p.origins, p.originPats, origin)
|
|
if p.dynamic || (allowed && value != "*") {
|
|
addVary(h, "Origin")
|
|
}
|
|
if !allowed {
|
|
return false
|
|
}
|
|
h.Set("Access-Control-Allow-Origin", value)
|
|
if p.allowAnyMethod {
|
|
h.Set("Access-Control-Allow-Methods", "*")
|
|
} else if p.methods != "" {
|
|
h.Set("Access-Control-Allow-Methods", p.methods)
|
|
}
|
|
if p.allowAnyHeader {
|
|
h.Set("Access-Control-Allow-Headers", "*")
|
|
} else if p.headers != "" {
|
|
h.Set("Access-Control-Allow-Headers", p.headers)
|
|
}
|
|
if p.exposed != "" {
|
|
h.Set("Access-Control-Expose-Headers", p.exposed)
|
|
}
|
|
if p.cfg.MaxAge > 0 {
|
|
h.Set("Access-Control-Max-Age", strconv.Itoa(p.cfg.MaxAge))
|
|
}
|
|
if p.cfg.SupportsCredentials {
|
|
h.Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
return true
|
|
}
|
|
|
|
// preflight answers an OPTIONS request from the policy.
|
|
func (p *corsPolicy) preflight(w http.ResponseWriter, r *http.Request) {
|
|
allowed := p.apply(w.Header(), r.Header.Get("Origin"))
|
|
writeOptions(w, r, allowed, p.allowAnyMethod, p.allowAnyHeader)
|
|
}
|
|
|
|
// addVary adds value to the Vary header unless a Vary line already names it.
|
|
func addVary(h http.Header, value string) {
|
|
for _, line := range h.Values("Vary") {
|
|
for _, v := range strings.Split(line, ",") {
|
|
if strings.EqualFold(strings.TrimSpace(v), value) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
h.Add("Vary", value)
|
|
}
|
|
|
|
func pathScopedCORS(cfg CORSConfig, next http.Handler) http.Handler {
|
|
globs := make([]*regexp.Regexp, 0, len(cfg.Paths))
|
|
for _, p := range cfg.Paths {
|
|
if re := compileLaravelGlob(p); re != nil {
|
|
globs = append(globs, re)
|
|
}
|
|
}
|
|
policy := newCORSPolicy(cfg)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !pathMatchesCORS(globs, r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
policy.preflight(w, r)
|
|
return
|
|
}
|
|
policy.apply(w.Header(), r.Header.Get("Origin"))
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// corsProfileName is the character set of a cors_profiles name, the plugin-id
|
|
// set without dots: lower-case letters, digits, underscore and dash.
|
|
var corsProfileName = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
|
|
|
// LoadCORSProfiles reads every child of http.cors_profiles. A profile takes
|
|
// the http.cors keys except paths (it applies to the routes that name
|
|
// cors:<profile>) and is validated like http.cors, with errors naming
|
|
// http.cors_profiles.<name>.<key>. A missing section yields an empty map.
|
|
func LoadCORSProfiles(cfg *compass.Config) (map[string]CORSConfig, error) {
|
|
out := map[string]CORSConfig{}
|
|
if cfg == nil || !cfg.Has("http.cors_profiles") {
|
|
return out, nil
|
|
}
|
|
raw, _ := cfg.Lookup("http.cors_profiles")
|
|
tree, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("surf: http.cors_profiles must be a map of profile names, got %T", raw)
|
|
}
|
|
for name := range tree {
|
|
if !corsProfileName.MatchString(name) {
|
|
return nil, fmt.Errorf("surf: http.cors_profiles.%s: a profile name uses lower-case letters, digits, underscore and dash", name)
|
|
}
|
|
prefix := "http.cors_profiles." + name
|
|
var pc CORSConfig
|
|
if err := cfg.LoadSection(prefix, &pc); err != nil {
|
|
return nil, fmt.Errorf("surf: %s: %w", prefix, err)
|
|
}
|
|
if len(pc.Paths) > 0 {
|
|
return nil, fmt.Errorf("surf: %s.paths is not allowed: a profile applies to the routes that name cors:%s", prefix, name)
|
|
}
|
|
if err := validateCORSConfig(prefix, pc); err != nil {
|
|
return nil, err
|
|
}
|
|
out[name] = pc
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// profileMiddleware sets the profile's CORS headers on a non-OPTIONS request;
|
|
// preflights reach the OPTIONS route compile registers instead. wrap runs it
|
|
// outermost, outside the body limit, locale and recovery, so every answer of
|
|
// the route carries the headers, refusals and recovered panics included.
|
|
func (p *corsPolicy) profileMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodOptions {
|
|
p.apply(w.Header(), r.Header.Get("Origin"))
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// routeCORSProfile returns the profile a route names through cors:<profile>,
|
|
// or "" when it names none. Two different profiles on one route are an error.
|
|
func routeCORSProfile(rt route) (string, error) {
|
|
profile := ""
|
|
for _, name := range rt.middleware {
|
|
base, param, ok := strings.Cut(name, ":")
|
|
if !ok || base != "cors" {
|
|
continue
|
|
}
|
|
if profile != "" && profile != param {
|
|
return "", fmt.Errorf("surf: route %s %s (plugin %q) names two CORS profiles, %q and %q", rt.method, rt.path, rt.pluginID, profile, param)
|
|
}
|
|
profile = param
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
// addCORSPreflights registers one OPTIONS route per path whose routes name
|
|
// cors:<profile>, answering the preflight from that profile. ServeMux would
|
|
// otherwise answer such a preflight with 405 before any route middleware
|
|
// runs. A path that two profiles claim, a path the global http.cors.paths
|
|
// also matches (the global wrapper would answer its preflight first) and an
|
|
// OPTIONS route a plugin already declared are boot errors.
|
|
func (r *Router) addCORSPreflights() error {
|
|
if len(r.corsProfiles) == 0 {
|
|
return nil
|
|
}
|
|
globs := make([]*regexp.Regexp, 0, len(r.corsCfg.Paths))
|
|
for _, p := range r.corsCfg.Paths {
|
|
if re := compileLaravelGlob(p); re != nil {
|
|
globs = append(globs, re)
|
|
}
|
|
}
|
|
type claim struct {
|
|
profile string
|
|
owner route
|
|
}
|
|
claims := map[string]claim{}
|
|
var order []string
|
|
for _, rt := range r.routes {
|
|
if rt.preflight {
|
|
continue
|
|
}
|
|
profile, err := routeCORSProfile(rt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if profile == "" {
|
|
continue
|
|
}
|
|
if _, ok := r.corsProfiles[profile]; !ok {
|
|
return fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, profile, profile)
|
|
}
|
|
if pathMatchesCORS(globs, rt.path) {
|
|
return fmt.Errorf("surf: route %s %s (plugin %q) names cors:%s but its path also matches http.cors.paths; the global CORS wrapper would answer its preflight first", rt.method, rt.path, rt.pluginID, profile)
|
|
}
|
|
if prev, ok := claims[rt.path]; ok {
|
|
if prev.profile != profile {
|
|
return fmt.Errorf("surf: path %s has routes with different CORS profiles: %s %s (plugin %q) names cors:%s, %s %s (plugin %q) names cors:%s", rt.path, prev.owner.method, prev.owner.path, prev.owner.pluginID, prev.profile, rt.method, rt.path, rt.pluginID, profile)
|
|
}
|
|
continue
|
|
}
|
|
claims[rt.path] = claim{profile: profile, owner: rt}
|
|
order = append(order, rt.path)
|
|
}
|
|
for _, path := range order {
|
|
c := claims[path]
|
|
key := http.MethodOptions + " " + path
|
|
if prev, ok := r.seen[key]; ok {
|
|
return fmt.Errorf("surf: route %s registered by %s collides with the preflight route cors:%s needs", key, prev, c.profile)
|
|
}
|
|
r.seen[key] = corsPreflightOwner
|
|
policy := r.corsProfiles[c.profile]
|
|
r.routes = append(r.routes, route{
|
|
pluginID: corsPreflightOwner,
|
|
method: http.MethodOptions,
|
|
path: path,
|
|
handler: http.HandlerFunc(policy.preflight),
|
|
constraints: append([]Constraint(nil), c.owner.constraints...),
|
|
raw: true,
|
|
preflight: true,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// corsPreflightOwner is the plugin ID route:list shows for the OPTIONS
|
|
// routes addCORSPreflights registers.
|
|
const corsPreflightOwner = "surf"
|
|
|
|
// writeOptions answers an OPTIONS request on a CORS path with 204 and the
|
|
// headers Laravel's HandleCors (fruitcake/php-cors) sends, so recorded PHP
|
|
// preflights replay unchanged. Every answer carries Symfony's default
|
|
// Cache-Control "no-cache, private". A preflight (an Origin and an
|
|
// Access-Control-Request-Method) also carries the Content-Type PHP's SAPI
|
|
// adds to a response that sets none ("text/html; charset=UTF-8") and Vary
|
|
// on the two request headers; when any method or header is allowed, the
|
|
// Allow-Methods and Allow-Headers values echo the requested method
|
|
// (upper-cased) and headers, as php-cors does, instead of "*".
|
|
func writeOptions(w http.ResponseWriter, r *http.Request, allowed, allowAnyMethod, allowAnyHeader bool) {
|
|
h := w.Header()
|
|
h.Set("Cache-Control", "no-cache, private")
|
|
method := r.Header.Get("Access-Control-Request-Method")
|
|
if r.Header.Get("Origin") != "" && method != "" {
|
|
h.Set("Content-Type", "text/html; charset=UTF-8")
|
|
h.Add("Vary", "Access-Control-Request-Method, Access-Control-Request-Headers")
|
|
if allowed && allowAnyMethod {
|
|
h.Set("Access-Control-Allow-Methods", strings.ToUpper(method))
|
|
}
|
|
if allowed && allowAnyHeader {
|
|
if requested := r.Header.Get("Access-Control-Request-Headers"); requested != "" {
|
|
h.Set("Access-Control-Allow-Headers", requested)
|
|
}
|
|
}
|
|
} else {
|
|
h.Add("Vary", "Access-Control-Request-Method")
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func corsAllowOrigin(allowAny bool, origins map[string]struct{}, pats []*regexp.Regexp, origin string) (bool, string) {
|
|
if allowAny {
|
|
return true, "*"
|
|
}
|
|
if origin == "" {
|
|
return false, ""
|
|
}
|
|
if _, ok := origins[origin]; ok {
|
|
return true, origin
|
|
}
|
|
for _, re := range pats {
|
|
if re.MatchString(origin) {
|
|
return true, origin
|
|
}
|
|
}
|
|
return false, ""
|
|
}
|
|
|
|
func pathMatchesCORS(globs []*regexp.Regexp, urlPath string) bool {
|
|
trimmed := strings.TrimPrefix(urlPath, "/")
|
|
for _, re := range globs {
|
|
if re.MatchString(trimmed) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func compileLaravelGlob(pattern string) *regexp.Regexp {
|
|
pattern = strings.TrimPrefix(pattern, "/")
|
|
var b strings.Builder
|
|
b.WriteString("(?s)^")
|
|
for i := 0; i < len(pattern); i++ {
|
|
switch pattern[i] {
|
|
case '*':
|
|
b.WriteString(".*")
|
|
case '?':
|
|
b.WriteByte('.')
|
|
default:
|
|
b.WriteString(regexp.QuoteMeta(pattern[i : i+1]))
|
|
}
|
|
}
|
|
b.WriteByte('$')
|
|
re, err := regexp.Compile(b.String())
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return re
|
|
}
|
|
|
|
func containsStar(vals []string) bool {
|
|
for _, v := range vals {
|
|
if v == "*" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|