package validate import ( "strings" "testing" "jarl/internal/dnsname" "jarl/internal/model" "jarl/internal/rr" ) type builder struct { s *model.State id int64 } func newBuilder() *builder { return &builder{s: model.NewState()} } func (b *builder) next() int64 { b.id++; return b.id } func (b *builder) view(name string, clients ...string) *model.View { v := &model.View{ID: b.next(), Name: name, Position: len(b.s.Views), MatchClients: clients} b.s.Views[v.ID] = v return v } func (b *builder) zone(name string, view *model.View, typ string) *model.Zone { z := &model.Zone{ID: b.next(), Name: dnsname.Fqdn(name), Type: typ, DefaultTTL: 3600, SOA: model.SOA{MName: "ns1." + dnsname.Fqdn(name), RName: "hostmaster." + dnsname.Fqdn(name), Serial: 1, Refresh: 3600, Retry: 900, Expire: 1209600, Minimum: 300}} if view != nil { z.ViewID = view.ID } b.s.Zones[z.ID] = z return z } // primary creates a valid primary zone with NS and glue. func (b *builder) primary(name string, view *model.View) *model.Zone { z := b.zone(name, view, model.ZonePrimary) b.rec(z, "@", "NS", "ns1") b.rec(z, "ns1", "A", "192.0.2.1") return z } func (b *builder) rec(z *model.Zone, name, typ, data string) *model.Record { owner, err := dnsname.Owner(name, z.Name) if err != nil { panic(err) } p, err := rr.Parse(z.Name, owner, typ, data) if err != nil { panic(name + " " + typ + " " + data + ": " + err.Error()) } r := &model.Record{ID: b.next(), ZoneID: z.ID, Name: owner, Type: p.Type, Data: p.Data} b.s.Records[r.ID] = r return r } func (b *builder) opt(scope string, scopeID int64, name, value string) *model.Option { o := &model.Option{ID: b.next(), Scope: scope, ScopeID: scopeID, Name: name, Value: value} b.s.Options[o.ID] = o return o } func codes(ps []Problem, sev Severity) []string { var out []string for _, p := range ps { if p.Severity == sev { out = append(out, p.Code) } } return out } func hasCode(ps []Problem, code string) bool { for _, p := range ps { if p.Code == code { return true } } return false } func TestValidZoneIsClean(t *testing.T) { b := newBuilder() z := b.primary("example.com", nil) b.rec(z, "www", "A", "192.0.2.10") b.rec(z, "www", "AAAA", "2001:db8::10") b.rec(z, "@", "MX", "10 mail") b.rec(z, "mail", "A", "192.0.2.25") b.rec(z, "@", "TXT", "v=spf1 mx -all") b.rec(z, "_dmarc", "TXT", "v=DMARC1; p=none") b.rec(z, "ftp", "CNAME", "www") b.rec(z, "_sip._tcp", "SRV", "0 5 5060 www") b.rec(z, "*", "A", "192.0.2.99") b.rec(z, "sub", "NS", "ns.sub") b.rec(z, "ns.sub", "A", "192.0.2.53") b.rec(z, "sub", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") rz := b.primary("2.0.192.in-addr.arpa", nil) rz.SOA.MName = "ns1.example.com." b.rec(rz, "10", "PTR", "www.example.com.") b.opt(model.ScopeOptions, 0, "recursion", "yes") b.opt(model.ScopeOptions, 0, "allow-recursion", "{ 192.168.0.0/16; localhost; }") ps := State(b.s, Options{}) for _, p := range ps { if p.Severity != Info { t.Errorf("unexpected problem: %s (%s)", p, p.Code) } } } func TestRecordConflicts(t *testing.T) { cases := []struct { name string build func(b *builder, z *model.Zone) code string sev Severity }{ {"cname and A", func(b *builder, z *model.Zone) { b.rec(z, "www", "CNAME", "ns1") b.rec(z, "www", "A", "192.0.2.2") }, "cname_conflict", Error}, {"two cnames", func(b *builder, z *model.Zone) { b.rec(z, "www", "CNAME", "ns1") b.rec(z, "www", "CNAME", "other.example.org.") }, "cname_multiple", Error}, {"cname at apex", func(b *builder, z *model.Zone) { b.rec(z, "@", "CNAME", "other.example.org.") }, "cname_apex", Error}, {"duplicate", func(b *builder, z *model.Zone) { b.rec(z, "x", "A", "192.0.2.2") b.rec(z, "x", "A", "192.0.2.2") }, "record_duplicate", Error}, {"ttl mismatch", func(b *builder, z *model.Zone) { r := b.rec(z, "x", "A", "192.0.2.2") r.TTL = model.Ptr(uint32(60)) b.rec(z, "x", "A", "192.0.2.3") }, "rrset_ttl_mismatch", Error}, {"mx to cname", func(b *builder, z *model.Zone) { b.rec(z, "mail", "CNAME", "ns1") b.rec(z, "@", "MX", "10 mail") }, "mx_cname", Error}, {"srv to cname", func(b *builder, z *model.Zone) { b.rec(z, "alias", "CNAME", "ns1") b.rec(z, "_sip._tcp", "SRV", "0 0 5060 alias") }, "srv_cname", Error}, {"missing glue", func(b *builder, z *model.Zone) { b.rec(z, "sub", "NS", "ns.sub") }, "glue_missing", Error}, {"apex ns without address", func(b *builder, z *model.Zone) { b.rec(z, "@", "NS", "ns2") }, "ns_no_address", Error}, {"ds not at delegation", func(b *builder, z *model.Zone) { b.rec(z, "x", "DS", "12345 13 2 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") }, "ds_not_at_delegation", Error}, {"below dname", func(b *builder, z *model.Zone) { b.rec(z, "d", "DNAME", "other.example.org.") b.rec(z, "x.d", "A", "192.0.2.2") }, "below_dname", Error}, {"occluded", func(b *builder, z *model.Zone) { b.rec(z, "sub", "NS", "ns.other.org.") b.rec(z, "www.sub", "A", "192.0.2.2") }, "occluded", Warning}, {"cname loop", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "b") b.rec(z, "b", "CNAME", "a") }, "cname_loop", Error}, {"cname chain", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "b") b.rec(z, "b", "CNAME", "ns1") }, "cname_chain", Warning}, {"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning}, {"null mx not alone", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "0 .") b.rec(z, "@", "MX", "10 ns1") }, "null_mx_not_alone", Error}, {"two spf", func(b *builder, z *model.Zone) { b.rec(z, "@", "TXT", "v=spf1 mx -all") b.rec(z, "@", "TXT", "v=spf1 a -all") }, "spf_multiple", Warning}, {"mx target missing", func(b *builder, z *model.Zone) { b.rec(z, "@", "MX", "10 nomail") }, "target_no_address", Warning}, {"ns to cname", func(b *builder, z *model.Zone) { b.rec(z, "nsalias", "CNAME", "ns1") b.rec(z, "@", "NS", "nsalias") }, "ns_cname", Error}, } for _, c := range cases { b := newBuilder() z := b.primary("example.com", nil) c.build(b, z) ps := State(b.s, Options{}) found := false for _, p := range ps { if p.Code == c.code && p.Severity == c.sev { found = true } } if !found { t.Errorf("%s: expected %s %s, got errors %v warnings %v", c.name, c.sev, c.code, codes(ps, Error), codes(ps, Warning)) } } } func TestZoneRules(t *testing.T) { b := newBuilder() b.zone("empty.com", nil, model.ZonePrimary) // no NS b.zone("sec.com", nil, model.ZoneSecondary) // no primaries ps := State(b.s, Options{}) if !hasCode(ps, "apex_ns_missing") || !hasCode(ps, "zone_missing_option") { t.Errorf("got %v", codes(ps, Error)) } // Views: zones outside views are not allowed once views exist. b = newBuilder() b.primary("example.com", nil) b.view("internal", "192.168.0.0/16") ps = State(b.s, Options{}) if !hasCode(ps, "zone_outside_view") { t.Errorf("zone outside view not reported: %v", codes(ps, Error)) } // in-view must reference an earlier view. b = newBuilder() ext := b.view("external") in := b.view("internal", "10/8") b.primary("example.com", in) iv := b.zone("example.com", ext, model.ZoneInView) iv.InView = "internal" ps = State(b.s, Options{}) if !hasCode(ps, "zone_inview_invalid") { t.Errorf("in-view order not checked: %v", codes(ps, Error)) } if !hasCode(ps, "view_unreachable") { t.Errorf("unreachable view not reported: %v", codes(ps, Warning)) } in.Position, ext.Position = 0, 1 ps = State(b.s, Options{}) if HasErrors(ps) { t.Errorf("valid in-view setup has errors: %v", Errors(ps)) } } func TestACLsAndOptions(t *testing.T) { b := newBuilder() b.s.ACLs[1] = &model.ACL{ID: 1, Name: "lan", Elements: []string{"192.168.1.0/24", "!192.168.1.5", "missing", "key nokey"}} b.s.ACLs[2] = &model.ACL{ID: 2, Name: "a", Elements: []string{"b"}} b.s.ACLs[3] = &model.ACL{ID: 3, Name: "b", Elements: []string{"a"}} b.s.ACLs[4] = &model.ACL{ID: 4, Name: "any", Elements: []string{"10.0.0.0/8"}} b.s.ACLs[5] = &model.ACL{ID: 5, Name: "bad", Elements: []string{"10.0.0.1/8"}} b.id = 10 b.opt(model.ScopeOptions, 0, "recursion", "maybe") b.opt(model.ScopeOptions, 0, "frobnicate", "yes") b.opt(model.ScopeOptions, 0, "allow-query", "{ lan; ghost; }") b.opt(model.ScopeOptions, 0, "allow-transfer", "{ key nope; }") b.opt(model.ScopeOptions, 0, "directory", `"/var/cache/bind"`) b.opt(model.ScopeOptions, 0, "directory", `"/tmp"`) b.opt(model.ScopeOptions, 0, "dnssec-validation", "sometimes") b.opt(model.ScopeTopLevel, 0, "acl", "x { any; }") ps := State(b.s, Options{}) for _, want := range []string{"acl_ref_missing", "key_ref_missing", "acl_cycle", "acl_name_invalid", "acl_element_invalid", "option_value_invalid", "option_unknown", "option_duplicate", "option_managed"} { if !hasCode(ps, want) { t.Errorf("missing %s; got %v", want, codes(ps, Error)) } } if !hasCode(ps, "acl_shadowed") { t.Errorf("shadowed element not reported: %v", codes(ps, Warning)) } ps = State(b.s, Options{ExternalKeysUnknown: true}) for _, p := range ps { if p.Code == "key_ref_missing" { t.Error("key refs reported despite ExternalKeysUnknown") } } } func TestSecurityLints(t *testing.T) { // allow-query any without allow-recursion: open resolver. b := newBuilder() b.primary("example.com", nil) b.opt(model.ScopeOptions, 0, "allow-query", "{ any; }") ps := State(b.s, Options{}) if !hasCode(ps, "security_open_resolver") { t.Errorf("open resolver not detected: %v", codes(ps, Warning)) } // Fixed by recursion no. b.opt(model.ScopeOptions, 0, "recursion", "no") if hasCode(State(b.s, Options{}), "security_open_resolver") { t.Error("open resolver reported with recursion no") } // Through an ACL that contains any. b = newBuilder() b.s.ACLs[100] = &model.ACL{ID: 100, Name: "everyone", Elements: []string{"any"}} b.opt(model.ScopeOptions, 0, "allow-recursion", "{ everyone; }") if !hasCode(State(b.s, Options{}), "security_open_resolver") { t.Error("open resolver via ACL not detected") } // Inside a view that only matches internal clients it's fine. b = newBuilder() v := b.view("internal", "192.168.0.0/16") b.opt(model.ScopeView, v.ID, "allow-recursion", "{ any; }") if hasCode(State(b.s, Options{}), "security_open_resolver") { t.Error("open resolver reported for internal-only view") } // Dynamic updates are rejected. b = newBuilder() z := b.primary("example.com", nil) b.opt(model.ScopeZone, z.ID, "allow-update", "{ key ddns; }") ps = State(b.s, Options{ExternalKeys: []string{"ddns"}}) if !hasCode(ps, "dynamic_update_unsupported") { t.Errorf("allow-update not rejected: %v", codes(ps, Error)) } b.s.Options[b.id].Value = "{ none; }" if hasCode(State(b.s, Options{}), "dynamic_update_unsupported") { t.Error("allow-update { none; } rejected") } // Open transfers. b.opt(model.ScopeZone, z.ID, "allow-transfer", "{ any; }") if !hasCode(State(b.s, Options{}), "security_open_transfer") { t.Error("open transfer not detected") } } func TestIntroduced(t *testing.T) { b := newBuilder() z := b.primary("example.com", nil) before := State(b.s, Options{}) b.rec(z, "www", "CNAME", "ns1") b.rec(z, "www", "TXT", "x") after := State(b.s, Options{}) intro := Introduced(before, after) if len(intro) == 0 || intro[0].Code != "cname_conflict" { t.Errorf("Introduced = %v", intro) } if len(Introduced(after, after)) != 0 { t.Error("same state introduced problems") } } func TestRecordLimit(t *testing.T) { b := newBuilder() z := b.primary("example.com", nil) for i := 0; i < 101; i++ { b.rec(z, "many", "TXT", strings.Repeat("x", i+1)) } if !hasCode(State(b.s, Options{}), "rrset_too_large") { t.Error("RRset over 100 records not reported") } b.opt(model.ScopeOptions, 0, "max-records-per-type", "0") if hasCode(State(b.s, Options{}), "rrset_too_large") { t.Error("max-records-per-type 0 (unlimited) not honoured") } }