Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.
A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.
Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
12 KiB
Go
388 lines
12 KiB
Go
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
|
|
}
|