Internet exposure checks and response rate limiting

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 10:38:56 +01:00
1 parent cf550bd512
commit bdd7acfd3b
50 files changed
+2019 -52

No files matched your search

+9 -1
View File
@@ -563,10 +563,15 @@ func (c *checker) checkSecurity() {
for _, v := range views {
ctxs = append(ctxs, ctx{v, "view " + v.Name})
}
exposure := map[int64]Exposure{}
for _, x := range c.exposures() {
exposure[x.ViewID] = x
}
for _, x := range ctxs {
// A view only reachable from some clients (e.g. "internal") can
// recurse for everyone inside it without being an open resolver.
if x.v != nil && !c.listMatchesEveryone(x.v.MatchClients, true) {
// Without views, only a server marked private is left out.
if x := exposure[viewID(x.v)]; !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
continue
}
if c.effectiveBool(x.v, "recursion", true) {
@@ -608,6 +613,9 @@ func (c *checker) checkSecurity() {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "allow-update makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; remove allow-update (or set it to { none; }) and manage records through Jarl")
p.Zone, p.View = z.Name, c.s.ViewName(z)
}
if x, ok := exposure[z.ViewID]; ok && !x.Public && (x.ViewID != 0 || x.Override == model.ExposurePrivate) {
continue // only reachable by your own clients
}
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-transfer"); o != nil && namedconf.ContainsAny(c.amlOf(o)) {
p := c.warnf("security_open_transfer", "zone", z.ID, "allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key")
p.Zone, p.View = z.Name, c.s.ViewName(z)
+387
View File
@@ -0,0 +1,387 @@
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
}
+10
View File
@@ -11,6 +11,7 @@ package validate
import (
"fmt"
"net/netip"
"sort"
"strings"
@@ -37,6 +38,9 @@ type Problem struct {
Zone string `json:"zone,omitempty"`
View string `json:"view,omitempty"`
Name string `json:"name,omitempty"`
// Acknowledged is set when an exposure finding was accepted on
// purpose (it is then info, not a warning).
Acknowledged *model.Acknowledgement `json:"acknowledged,omitempty"`
}
// Key identifies a problem independently of object IDs, so problems can be
@@ -67,6 +71,10 @@ type Options struct {
// ExternalKeysUnknown is set when an included file could not be read,
// so references to unknown keys are not reported.
ExternalKeysUnknown bool
// LocalAddrs are this host's addresses: a server without views is
// public when named listens on one that isn't private
// (docs/exposure.md).
LocalAddrs []netip.Addr
}
// Result holds the problems found.
@@ -140,8 +148,10 @@ func State(s *model.State, opts Options) []Problem {
c.checkZones()
c.checkOptions()
c.checkSecurity()
c.checkExposure()
c.checkDNSSEC()
c.checkBlocking()
c.applyAcks()
sortProblems(c.problems)
return c.problems
}