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) 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) 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) } return out } // 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, "") && !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 }