// Package prom fetches metrics from the Prometheus an alert came from. // // The address to query is the alert's generatorURL, which arrives in a webhook // payload and is therefore chosen by whoever holds an integration key. Left // unchecked, that would let the key holder make this server issue requests to // any host it can reach. Everything here is built around the Allowlist so that // cannot happen: a host is queried only if the operator listed it. package prom import ( "fmt" "net" "net/netip" "net/url" "path" "strings" ) // Allowlist is the set of Prometheus servers this install may query. The zero // value, and an empty list, permit nothing, which is what keeps the feature off // until an operator opts in. // // An entry is one of: // // - a host name, optionally with a port: prometheus.monitoring.svc, prom:9090 // - a wildcard host name: "*" stands for exactly one DNS label (or part of // one), so *.example.com matches a.example.com but neither example.com nor // a.b.example.com, and prometheus-*.monitoring.svc matches prometheus-0. // A leading "**" label matches one or more labels, so **.example.com also // matches a.b.example.com. // - an IP address or a CIDR range, optionally with a port: 10.0.0.0/8, // [fd00::]:9090 // // Without a port an entry matches any port. Matching is case-insensitive. type Allowlist struct { hosts []hostPattern nets []netEntry } type hostPattern struct { labels []string // lower-cased; a leading "**" label is kept as is port string // empty matches any port } type netEntry struct { prefix netip.Prefix port string } // ParseAllowlist validates and compiles the configured entries. It refuses an // entry that would admit every host (a bare "*", "*.*"): a typo there would // quietly turn the guard off. func ParseAllowlist(entries []string) (*Allowlist, error) { a := &Allowlist{} for _, raw := range entries { e := strings.ToLower(strings.TrimSpace(raw)) if e == "" { continue } if err := a.add(e); err != nil { return nil, fmt.Errorf("allowed host %q: %w", raw, err) } } return a, nil } func (a *Allowlist) add(e string) error { // CIDR first: its colons (IPv6) and slash would confuse the host:port split. if strings.Contains(e, "/") { host, port := e, "" if strings.HasPrefix(e, "[") { end := strings.Index(e, "]") if end < 0 { return fmt.Errorf("unterminated [") } host = e[1:end] rest := e[end+1:] if strings.HasPrefix(rest, ":") { port = rest[1:] } } p, err := netip.ParsePrefix(host) if err != nil { return fmt.Errorf("not a CIDR range: %w", err) } if err := checkPort(port); err != nil { return err } a.nets = append(a.nets, netEntry{prefix: p.Masked(), port: port}) return nil } // A bare IP address, IPv6 included. if ip, err := netip.ParseAddr(e); err == nil { a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen())}) return nil } host, port := e, "" if strings.HasPrefix(e, "[") || strings.Count(e, ":") == 1 { h, p, err := net.SplitHostPort(e) if err != nil { return fmt.Errorf("bad host:port: %w", err) } host, port = h, p if ip, err := netip.ParseAddr(host); err == nil { if err := checkPort(port); err != nil { return err } a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen()), port: port}) return nil } } if err := checkPort(port); err != nil { return err } host = strings.TrimSuffix(host, ".") if host == "" { return fmt.Errorf("empty host") } for _, r := range host { ok := r == '.' || r == '-' || r == '_' || r == '*' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') if !ok { return fmt.Errorf("unexpected character %q", r) } } labels := strings.Split(host, ".") for i, l := range labels { if l == "" { return fmt.Errorf("empty label") } if l == "**" && i != 0 { return fmt.Errorf("** is only allowed as the first label") } if strings.Contains(l, "**") && l != "**" { return fmt.Errorf("** must be a whole label") } } // The last label has to say something, or the entry is allow-all. if strings.Trim(labels[len(labels)-1], "*") == "" { return fmt.Errorf("would match every host: the last label cannot be a wildcard alone") } a.hosts = append(a.hosts, hostPattern{labels: labels, port: port}) return nil } func checkPort(p string) error { if p == "" { return nil } for _, r := range p { if r < '0' || r > '9' { return fmt.Errorf("bad port %q", p) } } return nil } // Empty reports whether nothing is allowed, i.e. the feature is off. func (a *Allowlist) Empty() bool { return a == nil || (len(a.hosts) == 0 && len(a.nets) == 0) } // Port returns the effective port of u: the explicit one, else the scheme's. func Port(u *url.URL) string { if p := u.Port(); p != "" { return p } if u.Scheme == "https" { return "443" } return "80" } // permits decides whether host:port may be contacted at ip. host is what the // URL said (a name or an IP literal), ip is where it actually resolved to; the // check runs on the resolved address at dial time so that a name that resolves // somewhere else than it did when it was listed (DNS rebinding) is caught. // // - An address inside a listed CIDR range or equal to a listed IP is allowed. // - A host name that matches a listed pattern is allowed, unless it resolved // to an address that is never a Prometheus (loopback, link-local, which // includes the cloud metadata address, unspecified, multicast). A wildcard // over a domain somebody else controls must not be a way to reach internal // services. To allow such an address deliberately, list it as a CIDR. func (a *Allowlist) permits(host, port string, ip netip.Addr) bool { if a == nil { return false } ip = ip.Unmap() for _, n := range a.nets { if n.prefix.Contains(ip) && (n.port == "" || n.port == port) { return true } } if forbiddenAddr(ip) { return false } if _, isIP := parseHostIP(host); isIP { return false // an IP literal is allowed only through a CIDR entry } return a.matchesName(host, port) } func parseHostIP(host string) (netip.Addr, bool) { ip, err := netip.ParseAddr(strings.Trim(host, "[]")) return ip, err == nil } // MatchesHost reports whether the URL's host is worth dialing at all: a listed // name, or an IP that falls in a listed range. It is the cheap early answer; // the dial-time check in permits is the one that is authoritative for names. func (a *Allowlist) MatchesHost(u *url.URL) bool { if a.Empty() { return false } host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) port := Port(u) if ip, ok := parseHostIP(host); ok { return a.permits(host, port, ip) } if a.matchesName(host, port) { return true } // A name outside the patterns can still be admitted by a CIDR entry once it // resolves; leave that to the dialer. return len(a.nets) > 0 } func (a *Allowlist) matchesName(host, port string) bool { labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".") for _, p := range a.hosts { if (p.port == "" || p.port == port) && matchLabels(p.labels, labels) { return true } } return false } func matchLabels(pattern, host []string) bool { if len(pattern) > 0 && pattern[0] == "**" { rest := pattern[1:] if len(host) <= len(rest) { return false } return matchLabels(rest, host[len(host)-len(rest):]) } if len(pattern) != len(host) { return false } for i := range pattern { // The pattern was restricted to [a-z0-9_.-*] when parsed, so path.Match // sees nothing but literals and "*" here. if ok, _ := path.Match(pattern[i], host[i]); !ok { return false } } return true } // forbiddenAddr is the set of addresses a Prometheus is never reached at and // that are dangerous to reach by mistake. Private ranges are deliberately not // in it: a Prometheus inside the cluster lives there. func forbiddenAddr(ip netip.Addr) bool { return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() }