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>
641 lines
20 KiB
Go
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
|
|
}
|