ACLs, views' match lists, options with address match lists and blocking client groups take ranges such as 10.0.10.101-10.0.10.199 or 2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too). Jarl stores the range as written and writes the fewest prefixes that cover exactly that range to named.conf, with the range as a comment. New package internal/iprange; namedconf gets an AMRange element (a range used to parse as an ACL name), ExpandRanges and statement comments. Mistakes are refused when entered, validation sees what ranges cover, and IPv6 ranges in the IPv4-mapped space get a warning. The web UI shows what a range becomes while typing (GET /catalog/ranges). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
633 lines
20 KiB
Go
633 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})
|
|
}
|
|
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) {
|
|
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 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
|
|
}
|