package validate import ( "fmt" "net/netip" "strings" "jarl/internal/model" "jarl/internal/namedconf" ) // Exposure says whether a view, or the server without views, can be // reached from the internet (docs/exposure.md). The exposure checks only // look at public ones. type Exposure struct { ViewID int64 `json:"view_id"` // 0: the server (no views) View string `json:"view,omitempty"` Public bool `json:"public"` Override string `json:"override,omitempty"` // public or private, set by hand Reason string `json:"reason"` } // Label names the scope in messages: "view external" or "the server". func (e Exposure) Label() string { if e.ViewID == 0 { return "the server" } return "view " + e.View } // RiskCodes are the findings that open the server up rather than leave it // less hardened: a change that introduces one must be confirmed // (ApplyOptions.AcceptRisk). var RiskCodes = map[string]bool{ "security_open_resolver": true, "security_open_transfer": true, "exposure_cache_snooping": true, "exposure_open_notify": true, "exposure_management": true, } // Ackable reports whether a finding can be acknowledged. func Ackable(code string) bool { return strings.HasPrefix(code, "exposure_") || strings.HasPrefix(code, "security_open_") } // Exposures classifies every view (or the server, without views). func Exposures(s *model.State, opts Options) []Exposure { c := &checker{s: s, opts: opts} return c.exposures() } func (c *checker) exposures() []Exposure { views := c.s.OrderedViews() if len(views) == 0 { return []Exposure{c.serverExposure()} } var out []Exposure for _, v := range views { out = append(out, c.viewExposure(v)) } return out } func (c *checker) viewExposure(v *model.View) Exposure { e := Exposure{ViewID: v.ID, View: v.Name} switch v.Exposure { case model.ExposurePublic: e.Public, e.Override, e.Reason = true, v.Exposure, "marked public" return e case model.ExposurePrivate: e.Override, e.Reason = v.Exposure, "marked private" return e } clients := c.flatten(v.MatchClients, 0) what, open := catchAll(clients) if len(v.MatchClients) == 0 { what, open = "nothing (so any client)", true } if !open { e.Reason = "match-clients lists only " + describeList(v.MatchClients) + ", no catch-all" return e } if len(v.MatchDestinations) > 0 { if dst := c.flatten(v.MatchDestinations, 0); onlyPrivate(dst) { e.Reason = "match-destinations lists only private addresses (" + describeList(v.MatchDestinations) + ")" return e } } e.Public, e.Reason = true, "match-clients ends in "+what return e } // serverExposure classifies a server without views: public when anyone // may query it and it listens on an address that isn't private. func (c *checker) serverExposure() Exposure { e := Exposure{} switch c.s.Settings.Exposure { case model.ExposurePublic: e.Public, e.Override, e.Reason = true, model.ExposurePublic, "marked public" return e case model.ExposurePrivate: e.Override, e.Reason = model.ExposurePrivate, "marked private" return e } if aml, _, set := c.effectiveAML(nil, "allow-query"); set { if _, open := catchAll(aml); !open { e.Reason = "allow-query has no catch-all" return e } } addr, ok := c.publicListenAddr() if !ok { e.Reason = "named listens on private addresses only" return e } e.Public, e.Reason = true, "anyone may query it, and named listens on "+addr return e } // publicListenAddr returns an address named listens on that isn't // private, from listen-on/listen-on-v6 and this host's addresses. func (c *checker) publicListenAddr() (string, bool) { for _, name := range []string{"listen-on", "listen-on-v6"} { opts := []*model.Option{} for _, o := range c.s.ScopedOptions(model.ScopeOptions, 0) { if o.Name == name { opts = append(opts, o) } } if len(opts) == 0 { // named listens on every interface by default. if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok { return a, true } continue } for _, o := range opts { elems := c.amlOf(o) if _, open := catchAll(elems); open { if a, ok := c.publicLocalAddr(name == "listen-on-v6"); ok { return a, true } continue } for _, e := range elems { if e.Kind == namedconf.AMPrefix && !e.Negated && !privatePrefix(e.Prefix) { return e.Prefix.Addr().String(), true } } } } return "", false } // publicLocalAddr returns one of this host's addresses that isn't // private (none known in tests). func (c *checker) publicLocalAddr(v6 bool) (string, bool) { for _, a := range c.opts.LocalAddrs { a = a.Unmap() if a.Is6() == v6 && !privatePrefix(netip.PrefixFrom(a, a.BitLen())) { return a.String(), true } } return "", false } // catchAll reports whether a list lets in clients from anywhere: a // non-negated "any", or a prefix so wide (shorter than /8 for IPv4, /16 // for IPv6) that it holds most of the internet. Negated elements before // it only take some clients out. func catchAll(elems []namedconf.AMElement) (string, bool) { for _, e := range elems { if e.Negated { continue } switch { case e.Kind == namedconf.AMName && e.Name == "any": return "any", true case e.Kind == namedconf.AMName && e.Name == "none": return "", false case e.Kind == namedconf.AMPrefix && e.Prefix.Addr().Is4() && e.Prefix.Bits() < 8, e.Kind == namedconf.AMPrefix && !e.Prefix.Addr().Is4() && e.Prefix.Bits() < 16: return e.Prefix.String(), true } } return "", false } var privateBlocks = []netip.Prefix{ netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("172.16.0.0/12"), netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fe80::/10"), netip.MustParsePrefix("::1/128"), } func privatePrefix(p netip.Prefix) bool { a := p.Addr().Unmap() for _, b := range privateBlocks { if b.Addr().Is4() == a.Is4() && b.Contains(a) && p.Bits() >= b.Bits() { return true } } return false } // onlyPrivate reports whether every address element is private (names // such as localhost and localnets count as private). func onlyPrivate(elems []namedconf.AMElement) bool { for _, e := range elems { if e.Negated { continue } switch e.Kind { case namedconf.AMPrefix: if !privatePrefix(e.Prefix) { return false } case namedconf.AMName: if e.Name == "any" { return false } case namedconf.AMOther, namedconf.AMNested: return false } } return true } func describeList(list []string) string { if len(list) > 4 { return strings.Join(list[:4], ", ") + ", …" } return strings.Join(list, ", ") } // checkExposure runs the exposure checks on public views (or the server). func (c *checker) checkExposure() { exps := c.exposures() anyPublic := false for _, x := range exps { if !x.Public { continue } anyPublic = true v := c.s.Views[x.ViewID] label := x.Label() warn := func(code, format string, args ...any) { p := c.warnf(code, "view", x.ViewID, "%s is reachable from the internet (%s): %s", label, x.Reason, fmt.Sprintf(format, args...)) p.View = x.View } if o := c.option(v, "rate-limit"); o == nil { warn("exposure_rate_limit", "it has no rate-limit, so it can be used to flood others with its answers (DNS amplification); start response rate limiting in log-only mode") } else if LogOnly(o) { p := c.infof("exposure_rate_limit_log_only", "view", x.ViewID, "%s: rate limiting is in log-only mode: named only logs what it would limit; check that nobody legitimate would be limited, then start enforcing", label) p.View = x.View } if !c.effectiveBool(v, "minimal-any", false) { warn("exposure_minimal_any", "ANY queries get full answers, a favourite for amplification; set minimal-any yes") } if !c.effectiveBool(v, "recursion", true) { if aml, _, set := c.effectiveAML(v, "allow-query-cache"); set { if _, open := catchAll(aml); open { warn("exposure_cache_snooping", "anyone may query its cache (allow-query-cache), which shows what your clients look up; set allow-query-cache { none; }") } } } if aml, _, set := c.effectiveAML(v, "allow-notify"); set { if _, open := catchAll(aml); open { warn("exposure_open_notify", "anyone may send it NOTIFY messages (allow-notify); allow only your primaries") } } } if !anyPublic { return } server := func(code, format string, args ...any) { c.warnf(code, "server", 0, format, args...) } if c.s.FindOption(model.ScopeOptions, 0, "version") == nil { server("exposure_version", "the server is reachable from the internet and tells anyone its exact BIND version (dig CH TXT version.bind), which helps attackers pick exploits; set version none") } if c.s.FindOption(model.ScopeOptions, 0, "hostname") == nil { server("exposure_hostname", "the server is reachable from the internet and tells anyone its host name (dig CH TXT hostname.bind); set hostname none") } for _, name := range []string{"controls", "statistics-channels"} { for _, o := range c.s.ScopedOptions(model.ScopeTopLevel, 0) { if o.Name != name { continue } if addr, ok := openManagement(o); ok { c.warnf("exposure_management", "option", o.ID, "%s listens on %s and accepts anyone there; management interfaces must not be reachable from the internet: use 127.0.0.1, or an allow list of your own hosts", name, addr) } } } } // option returns the option name for a view, or the server's. func (c *checker) option(v *model.View, name string) *model.Option { if v != nil { if o := c.s.FindOption(model.ScopeView, v.ID, name); o != nil { return o } } return c.s.FindOption(model.ScopeOptions, 0, name) } // LogOnly reports whether a rate-limit option is in log-only mode. func LogOnly(o *model.Option) bool { st, err := namedconf.ParseOptionValue(o.Name, o.Value) if err != nil || st.FirstBlock() == nil { return false } for _, s := range st.FirstBlock().Stmts { if s.Keyword() == "log-only" && len(s.Items) == 2 && s.Items[1].Value != nil { switch strings.ToLower(s.Items[1].Value.Text) { case "yes", "true", "1": return true } } } return false } // openManagement reports an inet clause of controls or // statistics-channels on an address that isn't loopback, open to anyone. func openManagement(o *model.Option) (string, bool) { st, err := namedconf.ParseOptionValue(o.Name, o.Value) if err != nil || st.FirstBlock() == nil { return "", false } for _, s := range st.FirstBlock().Stmts { if s.Keyword() != "inet" || len(s.Items) < 2 || s.Items[1].Value == nil { continue } addr := s.Items[1].Value.Text if a, err := netip.ParseAddr(addr); err == nil && a.IsLoopback() { continue } for i, it := range s.Items { if it.Value != nil && strings.EqualFold(it.Value.Text, "allow") && i+1 < len(s.Items) && s.Items[i+1].Block != nil { var elems []namedconf.AMElement for _, as := range s.Items[i+1].Block.Stmts { if e, err := namedconf.ParseAMElement(namedconf.Compact(as.Items)); err == nil { elems = append(elems, e.Expand()...) } } if _, open := catchAll(elems); open { return addr, true } } } } return "", false } // applyAcks turns acknowledged findings into info, saying who accepted // them, when and why. A finding of a view (or of a zone in it) uses the // view's acknowledgements, anything else the server's. func (c *checker) applyAcks() { for i := range c.problems { p := &c.problems[i] if p.Severity != Warning || !Ackable(p.Code) { continue } acks := c.s.Settings.Acknowledged if v := c.problemView(p); v != nil { acks = v.Acknowledged } if a := model.FindAck(acks, p.Code); a != nil { p.Severity = Info p.Acknowledged = a p.Message += fmt.Sprintf(" (acknowledged by %s on %s: %s)", a.By, a.At.Format("2 Jan 2006"), a.Reason) } } } func (c *checker) problemView(p *Problem) *model.View { switch p.ObjectType { case "view": return c.s.Views[p.ObjectID] case "zone": if z := c.s.Zones[p.ObjectID]; z != nil { return c.s.Views[z.ViewID] } } return nil }