Files
adamandClaude Opus 5.5 bdd7acfd3b 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>
2026-10-12 10:38:56 +01:00

641 lines
20 KiB
Go

package validate
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
"jarl/internal/catalog"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/namedconf"
)
// TSIGAlgorithms are the algorithms named supports, with the minimum
// recommended secret length in bytes (the hash output size).
var TSIGAlgorithms = map[string]int{
"hmac-md5": 16,
"hmac-sha1": 20,
"hmac-sha224": 28,
"hmac-sha256": 32,
"hmac-sha384": 48,
"hmac-sha512": 64,
}
var reViewName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
func (c *checker) checkSettings() {
st := c.s.Settings
for _, ns := range st.Nameservers {
if !dnsname.IsHostname(ns, false) {
c.errf("settings_invalid", "settings", 0, "default name server %q is not a valid host name", dnsname.Display(ns))
}
}
if st.RName != "" && !dnsname.IsMailbox(st.RName) {
c.errf("settings_invalid", "settings", 0, "default SOA contact %q is not a valid mailbox name", st.RName)
}
if st.DefaultTTL > maxTTL {
c.errf("settings_invalid", "settings", 0, "default TTL %d is larger than 2147483647", st.DefaultTTL)
}
switch st.SerialPolicy {
case model.SerialDate, model.SerialIncrement, model.SerialUnixtime:
default:
c.errf("settings_invalid", "settings", 0, "unknown serial policy %q", st.SerialPolicy)
}
}
func (c *checker) checkKeys() {
seen := map[string]bool{}
for _, k := range c.s.SortedKeys() {
label := "TSIG key " + k.Name
if seen[k.Name] {
c.errf("key_duplicate", "key", k.ID, "%s is defined more than once", label)
}
seen[k.Name] = true
if k.Name == "" {
c.errf("key_name_invalid", "key", k.ID, "TSIG key has no name")
} else if err := dnsname.CheckZoneName(dnsname.Fqdn(k.Name)); err != nil {
c.errf("key_name_invalid", "key", k.ID, "%s: invalid name: %v", label, err)
} else if strings.HasPrefix(strings.ToLower(k.Name), model.ViewKeyPrefix) {
c.errf("key_name_reserved", "key", k.ID, "%s: names starting with %s are kept for the keys Jarl gives views", label, model.ViewKeyPrefix)
}
minLen, ok := TSIGAlgorithms[k.Algorithm]
if !ok {
c.errf("key_algorithm_invalid", "key", k.ID, "%s: unsupported algorithm %q (use one of hmac-sha256, hmac-sha512, ...)", label, k.Algorithm)
}
b, err := base64.StdEncoding.DecodeString(k.Secret)
if err != nil || len(b) == 0 {
c.errf("key_secret_invalid", "key", k.ID, "%s: secret is not valid base64", label)
} else if ok && len(b) < minLen {
c.warnf("key_secret_weak", "key", k.ID, "%s: secret is only %d bytes; %d or more are recommended for %s", label, len(b), minLen, k.Algorithm)
}
if k.Algorithm == "hmac-md5" || k.Algorithm == "hmac-sha1" {
c.warnf("key_algorithm_weak", "key", k.ID, "%s: %s is outdated; prefer hmac-sha256", label, k.Algorithm)
}
}
}
// parseElements parses ACL-style element strings, reporting errors.
func (c *checker) parseElements(elems []string, report func(err error, elem string)) []namedconf.AMElement {
var out []namedconf.AMElement
for _, s := range elems {
e, err := namedconf.ParseAMElement(s)
if err != nil {
report(err, s)
continue
}
out = append(out, e)
}
return out
}
// checkRefs reports references to ACLs and keys that don't exist.
func (c *checker) checkRefs(elems []namedconf.AMElement, where string, objType string, id int64) {
acls, keys := namedconf.AMRefs(elems)
for _, a := range acls {
if c.s.ACLByName(a) == nil {
c.errf("acl_ref_missing", objType, id, "%s refers to ACL %q, which does not exist", where, a)
}
}
for _, k := range keys {
if !c.keyNames[model.NormalizeKeyName(k)] && !c.opts.ExternalKeysUnknown {
c.errf("key_ref_missing", objType, id, "%s refers to TSIG key %q, which does not exist", where, k)
}
}
}
func (c *checker) checkACLs() {
seen := map[string]bool{}
for _, a := range c.s.SortedACLs() {
label := "ACL " + a.Name
lname := strings.ToLower(a.Name)
if seen[lname] {
c.errf("acl_duplicate", "acl", a.ID, "%s is defined more than once (ACL names are case-insensitive)", label)
}
seen[lname] = true
if namedconf.BuiltinACLs[lname] {
c.errf("acl_name_invalid", "acl", a.ID, "%q is a built-in ACL name and can't be redefined", a.Name)
} else if err := namedconf.CheckACLName(a.Name); err != nil {
c.errf("acl_name_invalid", "acl", a.ID, "%v", err)
}
elems := c.parseElements(a.Elements, func(err error, s string) {
c.errf("acl_element_invalid", "acl", a.ID, "%s: element %q: %v", label, s, err)
})
if len(a.Elements) == 0 {
c.warnf("acl_empty", "acl", a.ID, "%s is empty and matches nothing", label)
}
c.checkRefs(elems, label, "acl", a.ID)
c.checkRanges(elems, label, "acl", a.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "acl", a.ID, "%s: %s", label, w)
}
}
// Cycles between ACLs.
state := map[string]int{} // 0 unvisited, 1 visiting, 2 done
var visit func(a *model.ACL, path []string)
visit = func(a *model.ACL, path []string) {
n := strings.ToLower(a.Name)
switch state[n] {
case 1:
c.errf("acl_cycle", "acl", a.ID, "ACLs refer to each other in a loop: %s", strings.Join(append(path, a.Name), " -> "))
return
case 2:
return
}
state[n] = 1
var elems []namedconf.AMElement
for _, s := range a.Elements {
if e, err := namedconf.ParseAMElement(s); err == nil {
elems = append(elems, e)
}
}
refs, _ := namedconf.AMRefs(elems)
for _, r := range refs {
if next := c.s.ACLByName(r); next != nil {
visit(next, append(path, a.Name))
}
}
state[n] = 2
}
for _, a := range c.s.SortedACLs() {
visit(a, nil)
}
}
func (c *checker) checkViews() {
seen := map[string]bool{}
for _, v := range c.s.OrderedViews() {
label := "view " + v.Name
ln := strings.ToLower(v.Name)
if seen[ln] {
c.errf("view_duplicate", "view", v.ID, "%s is defined more than once", label)
}
seen[ln] = true
if !reViewName.MatchString(v.Name) {
c.errf("view_name_invalid", "view", v.ID, "view name %q must start with a letter or digit and contain only letters, digits, '_', '-' and '.'", v.Name)
}
for field, list := range map[string][]string{"match-clients": v.MatchClients, "match-destinations": v.MatchDestinations} {
elems := c.parseElements(list, func(err error, s string) {
c.errf("view_acl_invalid", "view", v.ID, "%s: %s element %q: %v", label, field, s, err)
})
c.checkRefs(elems, label+" "+field, "view", v.ID)
c.checkRanges(elems, label+" "+field, "view", v.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "view", v.ID, "%s %s: %s", label, field, w)
}
}
}
// Views that can never match because an earlier view matches every
// client.
views := c.s.OrderedViews()
for i, v := range views {
if !c.viewMatchesEveryone(v) {
continue
}
for _, later := range views[i+1:] {
c.warnf("view_unreachable", "view", later.ID, "view %s is never used: view %s before it matches every client", later.Name, v.Name)
}
break
}
}
// viewMatchesEveryone reports whether a view matches all clients and
// destinations (and not just recursive queries).
func (c *checker) viewMatchesEveryone(v *model.View) bool {
if v.MatchRecursiveOnly != nil && *v.MatchRecursiveOnly {
return false
}
return c.listMatchesEveryone(v.MatchClients, true) && c.listMatchesEveryone(v.MatchDestinations, true)
}
// listMatchesEveryone reports whether an element list matches every
// address. emptyIsAny is true for lists that default to "any".
func (c *checker) listMatchesEveryone(list []string, emptyIsAny bool) bool {
if len(list) == 0 {
return emptyIsAny
}
return namedconf.ContainsAny(c.flatten(list, 0))
}
// flatten parses elements and expands references to user ACLs.
func (c *checker) flatten(list []string, depth int) []namedconf.AMElement {
var out []namedconf.AMElement
for _, s := range list {
e, err := namedconf.ParseAMElement(s)
if err != nil {
continue
}
if e.Kind == namedconf.AMName && !e.IsBuiltin() && depth < 10 {
if a := c.s.ACLByName(e.Name); a != nil {
inner := c.flatten(a.Elements, depth+1)
if e.Negated {
// A negated ACL reference: treat as a single
// negated element (restrictive).
out = append(out, namedconf.AMElement{Negated: true, Kind: namedconf.AMOther, Raw: "!" + e.Name})
continue
}
out = append(out, inner...)
continue
}
}
out = append(out, e.Expand()...)
}
return out
}
// checkRanges warns about IPv6 ranges in the IPv4-mapped space, which may
// match IPv4 clients too.
func (c *checker) checkRanges(elems []namedconf.AMElement, where, objType string, id int64) {
for _, e := range elems {
if e.Kind == namedconf.AMRange && e.Range.TouchesMapped4() {
c.warnf("range_mapped4", objType, id, "%s: range %s reaches into ::ffff:0:0/96, where named can see IPv4 clients as IPv6 addresses, so it may match IPv4 clients too; for IPv4 clients, write an IPv4 range", where, e.Range)
}
if e.Kind == namedconf.AMNested {
c.checkRanges(e.Nested, where, objType, id)
}
}
}
// optionScope maps an option to its catalog scope.
func (c *checker) optionScope(o *model.Option) (string, string, bool) {
switch o.Scope {
case model.ScopeOptions:
return catalog.ScopeOptions, "server options", true
case model.ScopeTopLevel:
return catalog.ScopeTop, "top-level statements", true
case model.ScopeView:
v := c.s.Views[o.ScopeID]
if v == nil {
return "", "", false
}
return catalog.ScopeView, "view " + v.Name, true
case model.ScopeZone:
z := c.s.Zones[o.ScopeID]
if z == nil {
return "", "", false
}
return catalog.ZoneScope(z.Type), "zone " + c.zoneLabel(z), true
}
return "", "", false
}
func (c *checker) checkOptions() {
cat := catalog.Default()
type key struct {
scope string
id int64
name string
}
counts := map[key]int{}
var opts []*model.Option
for _, o := range c.s.Options {
opts = append(opts, o)
}
sortOptions(opts)
for _, o := range opts {
scope, where, ok := c.optionScope(o)
if !ok {
c.errf("option_scope_invalid", "option", o.ID, "option %s belongs to a %s that does not exist", o.Name, o.Scope)
continue
}
name := strings.ToLower(o.Name)
st, known := cat.Lookup(scope, name)
if !known {
if o.Scope == model.ScopeZone {
z := c.s.Zones[o.ScopeID]
c.errf("option_unknown", "option", o.ID, "%s: %q is not a valid option for %s zones", where, o.Name, z.Type)
} else {
c.errf("option_unknown", "option", o.ID, "%s: unknown option %q", where, o.Name)
}
continue
}
if st.Managed {
c.errf("option_managed", "option", o.ID, "%s: %q can't be set as an option; it is managed through %s", where, o.Name, st.ManagedBy)
continue
}
switch st.Status {
case "deprecated":
c.warnf("option_deprecated", "option", o.ID, "%s: %q is deprecated in BIND 9.20", where, o.Name)
case "obsolete":
c.warnf("option_obsolete", "option", o.ID, "%s: %q is obsolete and has no effect (or is rejected) in BIND 9.20", where, o.Name)
case "experimental":
c.infof("option_experimental", "option", o.ID, "%s: %q is experimental", where, o.Name)
case "test only", "not configured":
c.warnf("option_unavailable", "option", o.ID, "%s: %q is %s in this BIND build", where, o.Name, st.Status)
}
k := key{o.Scope, o.ScopeID, st.Name}
counts[k]++
if counts[k] == 2 && !st.Multiple {
c.errf("option_duplicate", "option", o.ID, "%s: %q may only be set once", where, o.Name)
}
stmt, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil {
if strings.TrimSpace(o.Value) == "" && (name == "null" || name == "stderr") {
continue
}
c.errf("option_syntax", "option", o.ID, "%s: %s: %v", where, o.Name, err)
continue
}
if err := checkKind(st, stmt); err != nil {
c.errf("option_value_invalid", "option", o.ID, "%s: %s: %v (expected: %s)", where, o.Name, err, st.Grammar)
continue
}
label := where + " " + o.Name
if strings.Contains(st.Grammar, "<address_match_element>") && !strings.Contains(st.Grammar, "\n") {
if b := stmt.FirstBlock(); b != nil {
elems, err := namedconf.ParseAMList(b)
if err != nil {
c.errf("option_value_invalid", "option", o.ID, "%s: %v", label, err)
} else {
c.checkRefs(elems, label, "option", o.ID)
for _, w := range namedconf.Shadowed(elems) {
c.warnf("acl_shadowed", "option", o.ID, "%s: %s", label, w)
}
}
}
}
for _, kn := range keyRefs(stmt) {
if !c.keyNames[model.NormalizeKeyName(kn)] && !c.opts.ExternalKeysUnknown {
c.errf("key_ref_missing", "option", o.ID, "%s refers to TSIG key %q, which does not exist", label, kn)
}
}
}
}
func sortOptions(opts []*model.Option) {
for i := 1; i < len(opts); i++ {
for j := i; j > 0 && optLess(opts[j], opts[j-1]); j-- {
opts[j], opts[j-1] = opts[j-1], opts[j]
}
}
}
func optLess(a, b *model.Option) bool {
if a.Scope != b.Scope {
return a.Scope < b.Scope
}
if a.ScopeID != b.ScopeID {
return a.ScopeID < b.ScopeID
}
if a.Position != b.Position {
return a.Position < b.Position
}
return a.ID < b.ID
}
var (
reDuration = regexp.MustCompile(`(?i)^(\d+|(\d+[wdhms])+|P(\d+[YMWD])*(T(\d+[HMS])+)?)$`)
reInteger = regexp.MustCompile(`^\d+$`)
)
// checkKind does quick checks on simple option values. Anything it does
// not understand is left to named-checkconf.
func checkKind(st *catalog.Statement, stmt *namedconf.Stmt) error {
args := stmt.Items[1:]
single := func() (*namedconf.Value, error) {
if len(args) != 1 || args[0].Value == nil {
return nil, fmt.Errorf("expected a single value")
}
return args[0].Value, nil
}
switch st.Kind {
case catalog.KindBool:
v, err := single()
if err != nil {
return err
}
switch strings.ToLower(v.Text) {
case "yes", "no", "true", "false", "1", "0":
return nil
}
return fmt.Errorf("%q is not a boolean (use yes or no)", v.Text)
case catalog.KindInt:
v, err := single()
if err != nil {
return err
}
if !reInteger.MatchString(v.Text) {
return fmt.Errorf("%q is not a number", v.Text)
}
case catalog.KindDuration:
v, err := single()
if err != nil {
return err
}
if !reDuration.MatchString(v.Text) {
return fmt.Errorf("%q is not a duration (e.g. 3600, 1h30m or PT1H)", v.Text)
}
case catalog.KindString, catalog.KindWord:
if _, err := single(); err != nil {
return err
}
case catalog.KindEnum:
v, err := single()
if err != nil {
return err
}
for _, ch := range st.Choices {
if strings.EqualFold(ch, v.Text) {
return nil
}
}
if lv := strings.ToLower(v.Text); contains(st.Choices, "yes") && (lv == "true" || lv == "false" || lv == "1" || lv == "0") {
return nil
}
return fmt.Errorf("%q is not one of %s", v.Text, strings.Join(st.Choices, ", "))
case catalog.KindACL:
if len(args) != 1 || args[0].Block == nil {
return fmt.Errorf("expected an address match list in braces, e.g. { 192.168.0.0/16; localhost; }")
}
}
return nil
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// keyRefs finds TSIG key references in a statement: "key NAME" and
// "keys { NAME; ... }" / "keys NAME" anywhere in it.
func keyRefs(st *namedconf.Stmt) []string {
var out []string
var walk func(items []namedconf.Item)
walk = func(items []namedconf.Item) {
for i, it := range items {
if it.Value != nil && !it.Value.Quoted && i+1 < len(items) {
switch strings.ToLower(it.Value.Text) {
case "key":
if n := items[i+1].Value; n != nil {
out = append(out, n.Text)
}
case "keys":
if n := items[i+1].Value; n != nil {
out = append(out, n.Text)
} else if b := items[i+1].Block; b != nil {
for _, s := range b.Stmts {
if len(s.Items) > 0 && s.Items[0].Value != nil {
out = append(out, s.Items[0].Value.Text)
}
}
}
}
}
if it.Block != nil {
for _, s := range it.Block.Stmts {
walk(s.Items)
}
}
}
}
if st.Keyword() == "key" { // a key definition, not a reference
return nil
}
walk(st.Items)
return out
}
// effectiveAML finds the first of names set in the view (if any) or in the
// global options, returning its elements (with ACL references expanded).
func (c *checker) effectiveAML(v *model.View, names ...string) ([]namedconf.AMElement, string, bool) {
for _, n := range names {
if v != nil {
if o := c.s.FindOption(model.ScopeView, v.ID, n); o != nil {
return c.amlOf(o), n, true
}
}
if o := c.s.FindOption(model.ScopeOptions, 0, n); o != nil {
return c.amlOf(o), n, true
}
}
return nil, "", false
}
func (c *checker) amlOf(o *model.Option) []namedconf.AMElement {
st, err := namedconf.ParseOptionValue(o.Name, o.Value)
if err != nil || st.FirstBlock() == nil {
return nil
}
var list []string
for _, s := range st.FirstBlock().Stmts {
list = append(list, namedconf.Compact(s.Items))
}
return c.flatten(list, 0)
}
func (c *checker) effectiveBool(v *model.View, name string, def bool) bool {
var o *model.Option
if v != nil {
o = c.s.FindOption(model.ScopeView, v.ID, name)
}
if o == nil {
o = c.s.FindOption(model.ScopeOptions, 0, name)
}
if o == nil {
return def
}
switch strings.ToLower(strings.TrimSpace(o.Value)) {
case "yes", "true", "1":
return true
case "no", "false", "0":
return false
}
return def
}
// checkSecurity looks for configurations that are dangerous on an
// internet-facing server.
func (c *checker) checkSecurity() {
views := c.s.OrderedViews()
type ctx struct {
v *model.View
label string
}
var ctxs []ctx
if len(views) == 0 {
ctxs = append(ctxs, ctx{nil, "the server"})
}
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.
// 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) {
aml, from, set := c.effectiveAML(x.v, "allow-recursion", "allow-query-cache", "allow-query")
if set && namedconf.ContainsAny(aml) {
p := c.warnf("security_open_resolver", "view", viewID(x.v),
"%s offers recursion to any client (via %s), making it an open resolver that can be abused for DDoS amplification; set allow-recursion to your own networks", x.label, from)
if x.v != nil {
p.View = x.v.Name
}
}
}
if aml, _, set := c.effectiveAML(x.v, "allow-transfer"); set && namedconf.ContainsAny(aml) {
p := c.warnf("security_open_transfer", "view", viewID(x.v), "%s allows zone transfers (AXFR) to anyone; restrict allow-transfer to your secondaries or a TSIG key", x.label)
if x.v != nil {
p.View = x.v.Name
}
}
}
// Dynamic updates would be lost when Jarl rewrites zone files.
for _, z := range c.s.SortedZones() {
if z.Type != model.ZonePrimary {
continue
}
v := c.s.Views[z.ViewID]
if o := c.s.FindOption(model.ScopeZone, z.ID, "update-policy"); o != nil {
p := c.errf("dynamic_update_unsupported", "zone", z.ID, "update-policy makes the zone dynamic, but Jarl rewrites zone files and would discard dynamic updates; manage records through Jarl instead")
p.Zone, p.View = z.Name, c.s.ViewName(z)
continue
}
var aml []namedconf.AMElement
set := false
if o := c.s.FindOption(model.ScopeZone, z.ID, "allow-update"); o != nil {
aml, set = c.amlOf(o), true
} else {
aml, _, set = c.effectiveAML(v, "allow-update")
}
if set && !onlyNone(aml) {
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)
}
}
}
func onlyNone(elems []namedconf.AMElement) bool {
for _, e := range elems {
if !(e.Kind == namedconf.AMName && e.Name == "none" && !e.Negated) {
return false
}
}
return true
}
func viewID(v *model.View) int64 {
if v == nil {
return 0
}
return v.ID
}