diff --git a/config/config.go b/config/config.go index e67ba68f..070b7f5a 100644 --- a/config/config.go +++ b/config/config.go @@ -11,10 +11,12 @@ import ( "path/filepath" "strings" "time" + "unicode" "github.com/BurntSushi/toml" "github.com/miekg/dns" "github.com/semihalev/sdns/internal/dnsclient" + "github.com/semihalev/sdns/internal/dnsname" "github.com/semihalev/zlog/v2" ) @@ -51,36 +53,38 @@ type Config struct { // ForwardZones route individual zones to their own upstreams. They are // consulted before ForwarderServers, which remains the whole-server // setting: a query matching no zone resolves normally. - ForwardZones []ForwardZoneConfig `toml:"forward_zone"` - AccessList []string - LogLevel string - AccessLog string - Bind Addrs - BindTLS Addrs - BindDOH Addrs - BindDOQ Addrs - TLSCertificate string - TLSPrivateKey string - API string - BearerToken string //nolint:gosec // G117 - not a hardcoded credential, loaded from config file - Nullroute string - Nullroutev6 string - HostsFile string - OutboundIPs []string - OutboundIP6s []string - Timeout Duration - QueryTimeout Duration - Expire uint32 - CacheSize int - CachePersist bool `toml:"cache_persist"` - Prefetch uint32 - Maxdepth int - RateLimit int - ClientRateLimit int - NSID string - Blocklist []string - Whitelist []string - Chaos bool + ForwardZones []ForwardZoneConfig `toml:"forward_zone"` + AccessList []string + LogLevel string + AccessLog string + Bind Addrs + BindTLS Addrs + BindDOH Addrs + BindDOQ Addrs + TLSCertificate string + TLSPrivateKey string + API string + BearerToken string //nolint:gosec // G117 - not a hardcoded credential, loaded from config file + Nullroute string + Nullroutev6 string + HostsFile string + HostsFileCheckNames bool `toml:"hostsfilechecknames"` + HostsFileZones []string `toml:"hostsfilezones"` + OutboundIPs []string + OutboundIP6s []string + Timeout Duration + QueryTimeout Duration + Expire uint32 + CacheSize int + CachePersist bool `toml:"cache_persist"` + Prefetch uint32 + Maxdepth int + RateLimit int + ClientRateLimit int + NSID string + Blocklist []string + Whitelist []string + Chaos bool // QnameMinLevel is deprecated and retained so older configs keep // parsing. It capped minimization by delegation depth rather than by // the queries a lookup spends, which is what RFC 9156 section 2.3 @@ -345,6 +349,39 @@ func validDomainName(name string) bool { return ok } +// validHostsFileZone checks the DNS name structure used for hosts-file +// suffixes. These are generic DNS labels, not LDH host labels; only empty +// names, wildcard syntax, literal whitespace/control characters, and malformed +// or overlong DNS names are rejected. Decimal escapes may represent whitespace +// or control bytes. A trailing root dot is optional; "." names the root zone. +func validHostsFileZone(name string) bool { + if strings.IndexFunc(name, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) >= 0 { + return false + } + var wire [255]byte + packed, ok := dnsname.AppendWireName(wire[:0], name) + if !ok { + return false + } + // Inspect label contents after DNS escapes have been decoded. The length + // octet is not label content (and can itself equal the byte for '*'). + for i := 0; i < len(packed); { + labelLen := int(packed[i]) + if labelLen == 0 { + break + } + for _, b := range packed[i+1 : i+1+labelLen] { + if b == '*' { + return false + } + } + i += labelLen + 1 + } + return true +} + // ForwardZoneFor returns the most specific configured forward zone covering // qname, or nil when the query resolves normally. Most specific wins so a // narrower zone can be pointed somewhere else than the one containing it. @@ -966,6 +1003,12 @@ accesslist = [ # Leave empty to disable hostsfile = "" +# Validate each hosts-file name as an ASCII DNS hostname before importing it +hostsfilechecknames = false + +# DNS zones that limit which hosts-file names are imported +hostsfilezones = [] + # ============================ # Performance and Limits diff --git a/config/config_test.go b/config/config_test.go index 1d61d8ea..639b2ca7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1070,6 +1070,8 @@ func TestConfigDefaults(t *testing.T) { "rfc9520 = true", "serve_stale = false", "serve_stale_max_ttl = \"24h\"", + "hostsfilechecknames = false", + "hostsfilezones = []", "# Upstream Servers", "# API and Logging", "# Filtering and Blocking", diff --git a/config/validate.go b/config/validate.go index 051e592e..30314696 100644 --- a/config/validate.go +++ b/config/validate.go @@ -552,6 +552,11 @@ func (c *Config) validateTrustAndIdentity(add func(string, ...any)) { add("hostsfile = %q: %v", c.HostsFile, err) } } + for i, zone := range c.HostsFileZones { + if !validHostsFileZone(zone) { + add("hostsfilezones[%d] = %q: must be a valid DNS zone name", i, zone) + } + } // Only read when the feature is on, so a stale source left behind by an // operator who turned hyperlocal off does not stop the server. diff --git a/config/validate_test.go b/config/validate_test.go index 0a9945ec..bbede01e 100644 --- a/config/validate_test.go +++ b/config/validate_test.go @@ -11,6 +11,7 @@ import ( "math/big" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -2214,6 +2215,101 @@ func TestValidateRelativeAndAbsoluteAreOnePlace(t *testing.T) { } } +func TestValidateHostsFileZones(t *testing.T) { + for _, tc := range []struct { + name string + zones []string + want string + }{ + {name: "root", zones: []string{"."}}, + {name: "uppercase unrooted", zones: []string{"Corp.Example"}}, + {name: "uppercase rooted", zones: []string{"Corp.Example."}}, + {name: "escaped dot rooted", zones: []string{"foo\\.."}}, + {name: "escaped trailing dot unrooted", zones: []string{"foo\\."}}, + {name: "unicode generic label", zones: []string{"münchen.example."}}, + {name: "unicode ü label", zones: []string{"ü"}}, + {name: "unicode ü child", zones: []string{"child.ü"}}, + {name: "unicode generic label unrooted", zones: []string{"münchen.example"}}, + {name: "unicode child label", zones: []string{"child.münchen.example"}}, + {name: "escaped unicode bytes", zones: []string{`m\195\188nchen.example.`}}, + {name: "escaped whitespace bytes", zones: []string{`example\032zone`}}, + {name: "escaped control bytes", zones: []string{`example\001zone`}}, + {name: "one backslash before dot", zones: []string{`\.example`}}, + {name: "two backslashes before dot", zones: []string{`\\.example`}}, + {name: "three backslashes before dot", zones: []string{`\\\.example`}}, + {name: "four backslashes before dot", zones: []string{`\\\\.example`}}, + {name: "63 octet label", zones: []string{strings.Repeat("a", 63) + ".example"}}, + {name: "255 octet wire name", zones: []string{strings.Repeat("a", 63) + "." + strings.Repeat("b", 63) + "." + strings.Repeat("c", 63) + "." + strings.Repeat("d", 61)}}, + {name: "multiple zones", zones: []string{"example", "office.example.", "_services.example"}}, + {name: "asterisk byte in label length", zones: []string{strings.Repeat("a", 42) + ".example"}}, + {name: "invalid empty entry", zones: []string{"example.", ""}, want: "hostsfilezones[1]"}, + {name: "invalid wildcard", zones: []string{"*.example"}, want: "hostsfilezones[0]"}, + {name: "invalid wildcard character", zones: []string{"host*.example"}, want: "hostsfilezones[0]"}, + {name: "invalid escaped wildcard label", zones: []string{"\\042.example"}, want: "hostsfilezones[0]"}, + {name: "invalid escaped wildcard character", zones: []string{"host\\042.example"}, want: "hostsfilezones[0]"}, + {name: "invalid repeated dots", zones: []string{"a..example"}, want: "hostsfilezones[0]"}, + {name: "invalid whitespace", zones: []string{"example zone"}, want: "hostsfilezones[0]"}, + {name: "invalid literal control", zones: []string{"example\x01zone"}, want: "hostsfilezones[0]"}, + {name: "invalid long label", zones: []string{strings.Repeat("a", 64) + ".example"}, want: "hostsfilezones[0]"}, + {name: "invalid long wire name", zones: []string{strings.Repeat(strings.Repeat("a", 62)+".", 4) + "abcd"}, want: "hostsfilezones[0]"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := (&Config{HostsFileZones: tc.zones}).Validate() + if tc.want == "" && err != nil { + t.Fatalf("Validate() rejected usable zones: %v", err) + } + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("Validate() = %v, want an error naming %q", err, tc.want) + } + }) + } +} + +func TestValidateHostsFileOptionsReportTogether(t *testing.T) { + err := (&Config{ + DNSSEC: "maybe", + HostsFileZones: []string{"good.example", "*.bad.example", ""}, + }).Validate() + if err == nil { + t.Fatal("Validate() accepted invalid hosts-file zones") + } + for _, want := range []string{"dnssec", "hostsfilezones[1]", "hostsfilezones[2]"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Validate() = %v, missing a problem for %q", err, want) + } + } +} + +func TestLoadHostsFileOptionsAndDefaults(t *testing.T) { + dir := t.TempDir() + load := func(body string) *Config { + t.Helper() + path := filepath.Join(dir, "sdns.conf") + body = fmt.Sprintf("version = %q\ndirectory = %q\nipv6access = true\ndnssec = \"off\"\nrootservers = [\"192.0.2.53:53\"]\n", configver, filepath.Join(dir, "db")) + body + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path, "test") + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + return cfg + } + + defaults := load("") + if defaults.HostsFileCheckNames || len(defaults.HostsFileZones) != 0 { + t.Fatalf("omitted hosts-file options = (%v, %q), want (false, [])", defaults.HostsFileCheckNames, defaults.HostsFileZones) + } + + configured := load("hostsfilechecknames = true\nhostsfilezones = [\"Corp.Example.\", \"office.example\"]\n") + if !configured.HostsFileCheckNames { + t.Fatal("Load() lost hostsfilechecknames = true") + } + if want := []string{"Corp.Example.", "office.example"}; !reflect.DeepEqual(configured.HostsFileZones, want) { + t.Fatalf("HostsFileZones = %q, want %q", configured.HostsFileZones, want) + } +} + func TestValidateViewMode(t *testing.T) { for _, mode := range []string{"", "overlay", "authoritative-owner", "typo", "Overlay", " overlay "} { cfg := &Config{Views: []ViewConfig{{Zone: "office", Mode: mode}}} diff --git a/contrib/linux/sdns.conf b/contrib/linux/sdns.conf index 44492782..bade3fce 100644 --- a/contrib/linux/sdns.conf +++ b/contrib/linux/sdns.conf @@ -291,6 +291,12 @@ accesslist = [ # Leave empty to disable hostsfile = "" +# Validate each hosts-file name as an ASCII DNS hostname before importing it +hostsfilechecknames = false + +# DNS zones that limit which hosts-file names are imported +hostsfilezones = [] + # ============================ # Performance and Limits diff --git a/docs/_docs/configuration/access-control.md b/docs/_docs/configuration/access-control.md index baeac065..df462716 100644 --- a/docs/_docs/configuration/access-control.md +++ b/docs/_docs/configuration/access-control.md @@ -137,12 +137,39 @@ automatically. ```toml hostsfile = "/etc/hosts" +hostsfilechecknames = true +hostsfilezones = ["home.arpa."] ``` -Serves entries from a hosts file directly. Empty disables it. A name the file -lists with addresses of one family only gets NODATA for the other family; the -query is not sent upstream. For answers scoped to particular client networks -rather than served to everyone, use +Serves entries from a hosts file directly. Empty `hostsfile` disables it. +`hostsfilechecknames` and `hostsfilezones` are independent filters: the +first checks each name; the second limits names to configured DNS zones. +Setting `hostsfilechecknames = true` alone checks names without limiting their +zones. An empty zone list disables zone filtering. With both options omitted +or at their defaults, hosts-file importing keeps its existing behavior. + +When name checking is enabled, SDNS accepts ASCII RFC 952/1123-style +hostname labels containing letters, digits, and hyphens. Each label must begin +and end with a letter or digit, and may contain hyphens between them. Labels +are limited to 63 octets and names to 255 octets on the DNS wire. A trailing +dot is optional. A leading `*.` is allowed for a wildcard entry. An invalid +name is skipped with a warning that identifies its file and line; it contributes no +forward answer or PTR record. For a line with aliases, each name is considered +individually. The first accepted normal name is promoted to the row's primary +name for CNAME and PTR processing. Equivalent names in one row are imported once +when either filter is enabled; addresses from separate rows still aggregate. +A surviving wildcard keeps the existing wildcard-row precedence and does not +create a PTR record. + +`hostsfilezones` entries are DNS zones. Matching is label-aware and +case-insensitive: a zone matches its apex and subdomains, so `corp.example` +matches `printer.corp.example` but not `notcorp.example`. Root `.` and +names with or without a trailing dot are accepted. A name outside every +configured zone continues through normal resolution. + +A name the file lists with addresses of one family only gets NODATA for the +other family; the query is not sent upstream. For answers scoped to particular +client networks rather than served to everyone, use [views]({{ '/docs/features/views/' | relative_url }}). ## Per-domain metrics diff --git a/docs/_docs/reference/config-keys.md b/docs/_docs/reference/config-keys.md index ea726091..73f5a935 100644 --- a/docs/_docs/reference/config-keys.md +++ b/docs/_docs/reference/config-keys.md @@ -76,6 +76,8 @@ The page that explains each top-level key is listed under | `block_aaaa` | `false` | Suppress normal client IN/AAAA queries with unsigned NODATA, ahead of local answers, cache and DNS64; internal resolution and IPv6 transports are unaffected | | `accesslist` | `["0.0.0.0/0", "::0/0"]` | Clients allowed to query, narrow this; `[]` or omitted allows every client | | `hostsfile` | `""` | Serve entries from a hosts file | +| `hostsfilechecknames` | `false` | Reject malformed names from a hosts file before importing them; independent of zone filtering | +| `hostsfilezones` | `[]` | Optional DNS zones limiting imported hosts-file names; empty preserves unrestricted import | | `timeout` | `"2s"` | Per upstream query; `"0"` or omitted means `"2s"` | | `querytimeout` | `"10s"` | For one whole client query; `"0"` or omitted means `"10s"` | | `expire` | `600` | Seconds; lifetime cap of RFC 8020 subtree cuts and RFC 8198 proofs (fixed caps of 24 hours and 3 hours sit above it; `0` or omitted leaves only those). Neither mechanism runs with `dnssec = "off"` or `forwarderservers` set. Resolution failures use `failure_cache_*` | @@ -137,7 +139,8 @@ The page that explains each top-level key is listed under - [Access control and blocking]({{ '/docs/configuration/access-control/' | relative_url }}): `accesslist`, `ratelimit`, `clientratelimit`, `cookiesecret`, the `reflex*` keys, `blocklists`, `blocklist`, `whitelist`, `nullroute`, `nullroutev6`, - `blocklistdir`, `block_aaaa`, `hostsfile`, `domainmetrics`, `domainmetricslimit`. + `blocklistdir`, `block_aaaa`, `hostsfile`, `hostsfilechecknames`, + `hostsfilezones`, `domainmetrics`, `domainmetricslimit`. - [Views and forwarding]({{ '/docs/features/views/' | relative_url }}): `fallbackservers`, `forwarderservers`. - [Local root zone]({{ '/docs/features/hyperlocal-root/' | relative_url }}): diff --git a/internal/dnsname/presentation_wire_test.go b/internal/dnsname/presentation_wire_test.go new file mode 100644 index 00000000..99cc9606 --- /dev/null +++ b/internal/dnsname/presentation_wire_test.go @@ -0,0 +1,148 @@ +package dnsname + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestAppendWireNamePresentation(t *testing.T) { + for _, tc := range []struct { + name string + want []byte + }{ + {".", []byte{0}}, + {"Example.COM", []byte{7, 'E', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'C', 'O', 'M', 0}}, + {"Example.COM.", []byte{7, 'E', 'x', 'a', 'm', 'p', 'l', 'e', 3, 'C', 'O', 'M', 0}}, + {`a\.b`, []byte{3, 'a', '.', 'b', 0}}, + {`a\\b.`, []byte{3, 'a', '\\', 'b', 0}}, + {`\000.\255.\999`, []byte{1, 0, 1, 255, 1, 231, 0}}, + {`\1.\12.\12x.\1234`, []byte{1, '1', 2, '1', '2', 3, '1', '2', 'x', 2, 123, '4', 0}}, + {"ü", []byte{2, 0xc3, 0xbc, 0}}, + {"\\ü", []byte{2, 0xc3, 0xbc, 0}}, + {`\195\188.`, []byte{2, 0xc3, 0xbc, 0}}, + {"ü\\.x", []byte{4, 0xc3, 0xbc, '.', 'x', 0}}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := AppendWireName(nil, tc.name) + if !ok || !bytes.Equal(got, tc.want) { + t.Fatalf("AppendWireName(%q) = %v, %v; want %v, true", tc.name, got, ok, tc.want) + } + }) + } +} + +func TestAppendWireNameUnicodeFinalDot(t *testing.T) { + // Construct the expected wire bytes directly: UTF-8 before escaped dots + // must not change whether the final dot is label content or the root. + labelLengths := [...]byte{3, 3, 4, 4} + for slashes := 1; slashes <= 4; slashes++ { + for _, child := range []bool{false, true} { + name := "ü" + strings.Repeat("\\", slashes) + "." + label := []byte{0xc3, 0xbc} + for i := 0; i < slashes/2; i++ { + label = append(label, '\\') + } + if slashes%2 == 1 { + label = append(label, '.') + } + want := []byte{labelLengths[slashes-1]} + want = append(want, label...) + want = append(want, 0) + if child { + name = "child." + name + want = append([]byte{5, 'c', 'h', 'i', 'l', 'd'}, want...) + } + t.Run(fmt.Sprintf("slashes%d/child%v", slashes, child), func(t *testing.T) { + got, ok := AppendWireName(nil, name) + if !ok || !bytes.Equal(got, want) { + t.Fatalf("AppendWireName(%q) = %v, %v; want %v, true", name, got, ok, want) + } + }) + } + } +} + +func TestAppendWireNamePresentationLimits(t *testing.T) { + label63 := strings.Repeat("a", 63) + label61 := strings.Repeat("b", 61) + name255 := strings.Join([]string{label63, label63, label63, label61}, ".") + for _, tc := range []struct { + name string + ok bool + wireLen int + }{ + {label63, true, 65}, + {strings.Repeat("a", 64), false, 0}, + {strings.Repeat(`\097`, 63), true, 65}, + {strings.Repeat(`\097`, 64), false, 0}, + {name255, true, 255}, + {name255 + ".", true, 255}, + {name255 + "b", false, 0}, + {name255 + "b.", false, 0}, + {"", false, 0}, + {".a", false, 0}, + {"a..b", false, 0}, + {"a..", false, 0}, + {"..", false, 0}, + {"\\", false, 0}, + {"a.\\", false, 0}, + {"a\\", false, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := AppendWireName(nil, tc.name) + if ok != tc.ok || len(got) != tc.wireLen { + t.Fatalf("AppendWireName(%q) = %v, %v; want length %d, ok %v", tc.name, got, ok, tc.wireLen, tc.ok) + } + }) + } +} + +func TestAppendWireNameDestinationPrefix(t *testing.T) { + prefix := []byte{0xde, 0xad, 0xbe, 0xef} + for _, capacity := range []int{len(prefix), 512} { + for _, name := range []string{"a.b.", ".", "a..b", strings.Repeat("a", 64), strings.Repeat("a", 63) + ".b\\"} { + t.Run(fmt.Sprintf("cap%d/%s", capacity, name), func(t *testing.T) { + dst := make([]byte, len(prefix), capacity) + copy(dst, prefix) + got, ok := AppendWireName(dst, name) + want := prefix + switch name { + case "a.b.": + want = append(append([]byte(nil), prefix...), 1, 'a', 1, 'b', 0) + case ".": + want = append(append([]byte(nil), prefix...), 0) + } + if ok != (name == "a.b." || name == ".") || !bytes.Equal(got, want) || !bytes.Equal(dst, prefix) { + t.Fatalf("AppendWireName(%q): got %v, %v; want %v; original prefix %v", name, got, ok, want, dst) + } + }) + } + } + // Wire size counts only the appended name, even with a long prefix. + dst := bytes.Repeat([]byte{0xaa}, 256) + name := strings.Join([]string{strings.Repeat("a", 63), strings.Repeat("b", 63), strings.Repeat("c", 63), strings.Repeat("d", 61)}, ".") + got, ok := AppendWireName(dst, name) + if !ok || len(got) != len(dst)+255 || !bytes.Equal(got[:len(dst)], dst) { + t.Fatalf("255-octet name with prefix: length %d, ok %v", len(got), ok) + } + got, ok = AppendWireName(dst, name+"d") + if ok || !bytes.Equal(got, dst) { + t.Fatalf("256-octet name with prefix: got %v, ok %v", got, ok) + } +} + +func TestAppendWireNamePresentationRoundTripEveryByte(t *testing.T) { + for oct := 0; oct < 256; oct++ { + want := []byte{1, byte(oct), 0} + presentation, ok := AppendPresentation(nil, want) + if !ok { + t.Fatalf("AppendPresentation refused byte %d", oct) + } + got, ok := AppendWireName(nil, string(presentation)) + if !ok || !bytes.Equal(got, want) { + t.Fatalf("byte %d, presentation %q: got %v, %v; want %v", oct, presentation, got, ok, want) + } + } +} diff --git a/internal/dnsname/wire.go b/internal/dnsname/wire.go index 9d539714..48cc20d3 100644 --- a/internal/dnsname/wire.go +++ b/internal/dnsname/wire.go @@ -163,3 +163,54 @@ func WireLabelCount(wire []byte) (int, bool) { } return n, true } + +// AppendWireName appends an uncompressed wire-form name decoded from DNS +// presentation form, preserving case and every raw byte. Only unescaped dots +// separate labels; a final unescaped dot is optional, and "." is the root. +// A backslash escapes one byte, or exactly three decimal digits decoded +// modulo 256. Partial decimal escapes escape only their first digit. +// Empty names, dangling escapes, empty labels, labels over 63 octets, and +// names over 255 wire octets including the root refuse. On refusal, the +// returned slice contains only dst's original prefix; callers must use the +// returned slice because appending may have grown its backing array. +func AppendWireName(dst []byte, name string) ([]byte, bool) { + mark := len(dst) + if name == "" { + return dst, false + } + if name == "." { + return append(dst, 0), true + } + + dst = append(dst, 0) + label := mark + for i := 0; i < len(name); { + if name[i] == '.' { + n := len(dst) - label - 1 + if n == 0 { + return dst[:mark], false + } + dst[label] = byte(n) //nolint:gosec // G115 - decoded labels are bounded to 63 octets. + i++ + if i == len(name) { + return append(dst, 0), true + } + label = len(dst) + dst = append(dst, 0) + continue + } + if name[i] == '\\' && i+1 == len(name) { + return dst[:mark], false + } + if len(dst)-label-1 == 63 || len(dst)-mark >= maxWireNameOctets-1 { + return dst[:mark], false + } + // Decode bytes, not runes: escaped UTF-8 keeps its original octets. + // An escaped final dot is label content, never a root separator. + var oct byte + oct, i = decodeOctet(name, i) + dst = append(dst, oct) + } + dst[label] = byte(len(dst) - label - 1) //nolint:gosec // G115 - decoded labels are bounded to 63 octets. + return append(dst, 0), true +} diff --git a/middleware/hostsfile/hostsfile.go b/middleware/hostsfile/hostsfile.go index ae1e1ddb..d6b0bd9d 100644 --- a/middleware/hostsfile/hostsfile.go +++ b/middleware/hostsfile/hostsfile.go @@ -73,6 +73,7 @@ type HostEntry struct { // WildcardEntry represents a wildcard pattern. type WildcardEntry struct { + suffix string // canonical label-aware suffix when zones are configured Pattern string IPv4 []net.IP IPv6 []net.IP @@ -86,6 +87,8 @@ type Hostsfile struct { watcher *fsnotify.Watcher reloadTime time.Time ttl uint32 + checkNames bool + zones []string // private, lowercase FQDNs } // New creates a new Hostsfile middleware. @@ -95,8 +98,21 @@ func New(cfg *config.Config) *Hostsfile { } h := &Hostsfile{ - path: cfg.HostsFile, - ttl: 600, // 10 minutes default TTL + path: cfg.HostsFile, + ttl: 600, // 10 minutes default TTL + checkNames: cfg.HostsFileCheckNames, + zones: make([]string, len(cfg.HostsFileZones)), + } + + for i, zone := range cfg.HostsFileZones { + key, ok := canonicalImportKey(zone) + if !ok || (key == "" && zone != ".") { + zlog.Error("Invalid hosts file zone", "zone", zone) + return nil + } + // Canonical keys omit the root separator. Append it explicitly so + // escaped label content cannot be mistaken for the root. + h.zones[i] = key + "." } // Initial load @@ -322,7 +338,7 @@ func lookupKeyed[K interface{ ~string | ~[]byte }](h *Hostsfile, db *HostsDB, ke if len(db.wildcards) > 0 { k := string(key) for _, wc := range db.wildcards { - if matchWildcard(wc.Pattern, k) { + if wc.matches(k) { if len(wc.IPv4) > 0 { return buildARRs(k, wc.IPv4, h.ttl), true } @@ -339,7 +355,7 @@ func lookupKeyed[K interface{ ~string | ~[]byte }](h *Hostsfile, db *HostsDB, ke if len(db.wildcards) > 0 { k := string(key) for _, wc := range db.wildcards { - if matchWildcard(wc.Pattern, k) { + if wc.matches(k) { if len(wc.IPv6) > 0 { return buildAAAARRs(k, wc.IPv6, h.ttl), true } @@ -361,7 +377,7 @@ func lookupKeyed[K interface{ ~string | ~[]byte }](h *Hostsfile, db *HostsDB, ke if len(db.wildcards) > 0 { k := string(key) for _, wc := range db.wildcards { - if matchWildcard(wc.Pattern, k) { + if wc.matches(k) { return nil, true } } @@ -436,6 +452,11 @@ func (h *Hostsfile) load() error { continue } + hostnames = h.filterNames(hostnames, lineNo) + if len(hostnames) == 0 { + continue + } + // Check all hostnames for wildcards hasWildcard := false wildcardHostname := "" @@ -453,6 +474,9 @@ func (h *Hostsfile) load() error { Pattern: wildcardHostname, Timestamp: now, } + if len(h.zones) > 0 && strings.HasPrefix(wc.Pattern, "*.") { + wc.suffix = wc.Pattern[2:] + } if ip.To4() != nil { wc.IPv4 = []net.IP{ip} } else { @@ -565,6 +589,89 @@ func (h *Hostsfile) load() error { return nil } +// filterNames applies import policy before primary, alias, wildcard and PTR +// selection. With both controls disabled, historical spellings and duplicate +// handling are preserved. +func (h *Hostsfile) filterNames(names []string, lineNo int) []string { + if !h.checkNames && len(h.zones) == 0 { + return names + } + accepted := names[:0] + // Keep the first occurrence of each DNS identity in this row. + // Repeated names are not new aliases or additional addresses. + // Separate rows still contribute their addresses. + seen := make(map[string]struct{}, len(names)) + for _, hostname := range names { + if h.checkNames && !validHostname(hostname) { + zlog.Warn("Invalid hosts file hostname", "path", h.path, "line", lineNo, "hostname", hostname) + continue + } + name, ok := canonicalImportKey(hostname) + if !ok { + continue + } + if len(h.zones) > 0 { + fqdn := name + "." + allowed := false + for _, zone := range h.zones { + if dnsname.Sub(zone, fqdn) { + allowed = true + break + } + } + if !allowed { + continue + } + } + if _, duplicate := seen[name]; duplicate { + continue + } + seen[name] = struct{}{} + accepted = append(accepted, name) + } + return accepted +} + +// canonicalImportKey follows DNS wire identity: only ASCII case folds, and +// presentation escapes are canonicalized without mistaking escaped dots for +// label boundaries or the root. It is used only while loading the file. +func canonicalImportKey(name string) (string, bool) { + var wire [255]byte + packed, ok := dnsname.AppendWireName(wire[:0], name) + if !ok { + return "", false + } + var presentation [dnsname.MaxPresentationLength]byte + key, ok := dnsname.AppendFoldedKey(presentation[:0], packed) + return string(key), ok +} + +// validHostname accepts ASCII LDH hostnames and the supported *.suffix +// wildcard. Wire length includes every label's length byte and the root, +// including the wildcard label when present. +func validHostname(name string) bool { + name = strings.TrimSuffix(name, ".") + if len(name) == 0 || len(name)+2 > 255 { + return false + } + name = strings.TrimPrefix(name, "*.") + for _, label := range strings.Split(name, ".") { + if len(label) == 0 || len(label) > 63 || !hostnameAlnum(label[0]) || !hostnameAlnum(label[len(label)-1]) { + return false + } + for i := 1; i < len(label)-1; i++ { + if !hostnameAlnum(label[i]) && label[i] != '-' { + return false + } + } + } + return true +} + +func hostnameAlnum(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' +} + // setupWatcher creates a file watcher for auto-reload. func (h *Hostsfile) setupWatcher() error { watcher, err := fsnotify.NewWatcher() @@ -672,6 +779,15 @@ func parseLine(line string) (net.IP, []string, string) { return ip, fields[1:], comment } +// matches enforces real DNS label boundaries for scoped wildcards, while +// preserving the historical presentation-suffix match without zone policy. +func (wc *WildcardEntry) matches(name string) bool { + if wc.suffix != "" { + return dnsname.Sub(wc.suffix, name) + } + return matchWildcard(wc.Pattern, name) +} + // matchWildcard checks if a name matches a wildcard pattern. // // For "*.example.com" this must match the apex "example.com" diff --git a/middleware/hostsfile/hostsfile_policy_test.go b/middleware/hostsfile/hostsfile_policy_test.go new file mode 100644 index 00000000..cb458e15 --- /dev/null +++ b/middleware/hostsfile/hostsfile_policy_test.go @@ -0,0 +1,673 @@ +package hostsfile + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/miekg/dns" + "github.com/semihalev/sdns/config" + "github.com/semihalev/sdns/internal/dnsname" + "github.com/semihalev/sdns/internal/mock" + "github.com/semihalev/sdns/middleware" +) + +func policyHostsfile(t *testing.T, content string, checks bool, zones []string) *Hostsfile { + t.Helper() + h := New(&config.Config{HostsFile: createTempHostsFile(t, content), HostsFileCheckNames: checks, HostsFileZones: zones}) + if h == nil { + t.Fatal("hostsfile failed to load") + } + if h.watcher != nil { + if err := h.watcher.Close(); err != nil { + t.Fatal(err) + } + } + return h +} + +func TestValidHostname(t *testing.T) { + label := strings.Repeat("a", 63) + max := label + "." + label + "." + label + "." + strings.Repeat("b", 61) + wildcardMax := "*." + label + "." + label + "." + label + "." + strings.Repeat("b", 59) + for _, name := range []string{"localhost", "A0.b-2", "xn--caf-dma.example.", "*.Example.COM.", label, max, max + ".", wildcardMax} { + if !validHostname(name) { + t.Errorf("valid hostname rejected: %q", name) + } + } + for _, name := range []string{"", ".", "..", "a..b", ".a", "a..", "-a", "a-", "a.-b", "a_b", "café.example", `a\046b.example`, "*", "*.", "a*.example", "**.example", "*.*.example", strings.Repeat("a", 64), max + "b", wildcardMax + "b"} { + if validHostname(name) { + t.Errorf("invalid hostname accepted: %q", name) + } + } +} + +func TestHostsfilePolicyCombinations(t *testing.T) { + content := "192.0.2.1 good.example outside.test bad_name.example\n" + for _, checks := range []bool{false, true} { + for _, restricted := range []bool{false, true} { + t.Run(fmt.Sprintf("checks=%v/zones=%v", checks, restricted), func(t *testing.T) { + var zones []string + if restricted { + zones = []string{"EXAMPLE."} + } + h := policyHostsfile(t, content, checks, zones) + for _, tc := range []struct { + name string + want bool + }{{"good.example", true}, {"outside.test", !restricted}, {"bad_name.example", !checks}} { + if got := h.hostExists(h.getDB(), tc.name); got != tc.want { + t.Errorf("%s exists=%v want %v", tc.name, got, tc.want) + } + } + }) + } + } +} + +func TestHostsfilePolicyZonesAndNormalization(t *testing.T) { + content := "192.0.2.1 OUTSIDE.test Apex.Example. Alias.Example. example badexample x.badexample X.Other.\n" + + "192.0.2.2 *.WILD.Example.\n" + + "192.0.2.3 outside.test *.outside.test normal.example\n" + + "192.0.2.4 invalid*.example good.example *.Last.Example. ignored.example\n" + h := policyHostsfile(t, content, true, []string{"ExAmPlE.", "other"}) + db := h.getDB() + for _, name := range []string{"apex.example", "alias.example", "example", "x.other", "x.wild.example", "wild.example", "normal.example", "x.last.example"} { + if !h.hostExists(db, name) { + t.Errorf("missing accepted %s", name) + } + } + for _, name := range []string{"outside.test", "badexample", "x.badexample", "good.example", "ignored.example"} { + if h.hostExists(db, name) { + t.Errorf("unexpected accepted %s", name) + } + } + rrs, ok := h.lookupCNAME(db, "ALIAS.EXAMPLE.") + if !ok || rrs[0].(*dns.CNAME).Target != "apex.example." { + t.Fatalf("promoted alias CNAME=%v found=%v", rrs, ok) + } + if got := db.reverse["192.0.2.1"]; !reflect.DeepEqual(got, []string{"apex.example"}) { + t.Fatalf("reverse=%v", got) + } + if len(db.wildcards) != 2 || db.wildcards[1].Pattern != "*.last.example" { + t.Fatalf("wildcards=%v", db.wildcards) + } + root := policyHostsfile(t, "192.0.2.1 single outside.test\n", false, []string{"."}) + if len(root.getDB().hosts) != 2 { + t.Fatal("root zone must include every name") + } +} + +func TestHostsfilePolicyDisabledPreservesSpellings(t *testing.T) { + h := policyHostsfile(t, "192.0.2.1 Dotted.Example. bad_name\n192.0.2.2 *.UPPER.Example\n192.0.2.3 a*.example normal.example\n", false, nil) + db := h.getDB() + if db.hosts["dotted.example."] == nil || db.hosts["bad_name"] == nil { + t.Fatal("legacy exact storage changed") + } + if h.hostExists(db, "dotted.example.") || h.hostExists(db, "x.upper.example") || h.hostExists(db, "normal.example") { + t.Fatal("legacy lookup or wildcard selection changed") + } + if len(db.wildcards) != 2 || db.wildcards[0].Pattern != "*.UPPER.Example" || db.wildcards[1].Pattern != "a*.example" { + t.Fatal("legacy wildcard spelling changed") + } +} + +func TestHostsfilePolicyPromotionAggregationAndPTR(t *testing.T) { + content := "192.0.2.1 bad_name accepted.example ACCEPTED.EXAMPLE accepted.example alias.example alias.example\n" + + "2001:db8::1 outside.test ACCEPTED.EXAMPLE. alias.example\n" + + "192.0.2.2 first.example promoted.example\n" + + "2001:db8::2 bad_name promoted.example last.example\n" + + "192.0.2.3 repeat.example repeat.example repeat.example\n" + + "192.0.2.4 bad_name *.Last.Example. *.last.example outside.test\n" + h := policyHostsfile(t, content, true, []string{"example"}) + db := h.getDB() + for _, name := range []string{"accepted.example", "alias.example", "promoted.example"} { + a, af := h.lookupA(db, name) + aaaa, vf := h.lookupAAAA(db, name) + if !af || !vf || len(a) != 1 || len(aaaa) != 1 { + t.Errorf("%s A=%v AAAA=%v", name, a, aaaa) + } + } + for _, tc := range []struct{ name, target string }{{"alias.example", "accepted.example."}, {"promoted.example", "first.example."}, {"last.example", "promoted.example."}} { + rrs, ok := h.lookupCNAME(db, tc.name) + if !ok || rrs[0].(*dns.CNAME).Target != tc.target { + t.Errorf("%s CNAME=%v want %s", tc.name, rrs, tc.target) + } + } + if got := len(db.hosts["repeat.example"].IPv4); got != 1 { + t.Errorf("repeated names addresses=%d want1", got) + } + if _, ok := h.lookupCNAME(db, "repeat.example"); ok { + t.Fatal("a primary name repeated as an alias must not create a self-CNAME") + } + for _, tc := range []struct{ ip, target string }{{"192.0.2.1", "accepted.example."}, {"2001:db8::1", "accepted.example."}, {"2001:db8::2", "promoted.example."}} { + rrs, ok := h.lookupPTR(db, reverseName(tc.ip)) + if !ok || len(rrs) != 1 || rrs[0].(*dns.PTR).Ptr != tc.target { + t.Errorf("%s PTR=%v want %s", tc.ip, rrs, tc.target) + } + } + if len(db.ptrs["192.0.2.4"]) != 0 || db.hosts["bad_name"] != nil || db.hosts["outside.test"] != nil { + t.Fatal("rejected names entered database") + } +} + +func TestHostsfilePolicyDeduplicatesAcceptedNamesPerRow(t *testing.T) { + content := "192.0.2.10 Primary.Example. primary.example PRIMARY.EXAMPLE alias.example ALIAS.EXAMPLE. alias.example\n" + for _, checks := range []bool{false, true} { + for _, restricted := range []bool{false, true} { + if !checks && !restricted { + // With both controls off, the historical spelling and duplicate + // behavior is pinned separately below. + continue + } + t.Run(fmt.Sprintf("checks=%v/zones=%v", checks, restricted), func(t *testing.T) { + var zones []string + if restricted { + zones = []string{"example"} + } + h := policyHostsfile(t, content, checks, zones) + entry := h.getDB().hosts["primary.example"] + if entry == nil || len(entry.IPv4) != 1 || len(entry.aRRs) != 1 { + t.Fatalf("primary entry=%+v", entry) + } + if got := len(entry.Aliases); got != 1 || entry.Aliases[0] != "alias.example" { + t.Fatalf("aliases=%v want one canonical alias", entry.Aliases) + } + if _, ok := h.lookupCNAME(h.getDB(), "primary.example"); ok { + t.Fatal("primary name produced a self-CNAME") + } + cnames, ok := h.lookupCNAME(h.getDB(), "alias.example") + if !ok || len(cnames) != 1 || cnames[0].(*dns.CNAME).Target != "primary.example." { + t.Fatalf("alias CNAME=%v found=%v", cnames, ok) + } + }) + } + } + + // The zone matcher uses DNS wire identity too: these two spellings name + // the same escaped-dot label. Strict hostname checks reject both tokens. + h := policyHostsfile(t, "192.0.2.11 corp\\.example corp\\046example child.corp\\.example child.corp\\046example\n", false, []string{`corp\.example`}) + if got := len(h.getDB().hosts); got != 2 { + t.Fatalf("escaped equivalent zone names hosts=%v", h.getDB().hosts) + } + entry := h.getDB().hosts[`corp\.example`] + if entry == nil || len(entry.IPv4) != 1 { + t.Fatalf("escaped apex entry=%+v", entry) + } + if cnames, ok := h.lookupCNAME(h.getDB(), `child.corp\.example`); !ok || len(cnames) != 1 || cnames[0].(*dns.CNAME).Target != `corp\.example.` { + t.Fatalf("escaped child CNAME=%v found=%v", cnames, ok) + } + strict := policyHostsfile(t, "192.0.2.11 corp\\.example corp\\046example\n", true, []string{`corp\.example`}) + if len(strict.getDB().hosts) != 0 { + t.Fatalf("strict hostname checks accepted escaped raw tokens: %v", strict.getDB().hosts) + } +} + +func TestHostsfilePolicyDuplicateLegacyBehavior(t *testing.T) { + legacy := policyHostsfile(t, "192.0.2.20 repeat.example repeat.example repeat.example\n", false, nil) + if got := len(legacy.getDB().hosts["repeat.example"].IPv4); got != 3 { + t.Fatalf("disabled policy addresses=%d want3", got) + } + if cnames, ok := legacy.lookupCNAME(legacy.getDB(), "repeat.example"); !ok || len(cnames) != 1 || cnames[0].(*dns.CNAME).Target != "repeat.example." { + t.Fatalf("disabled policy self-CNAME=%v found=%v", cnames, ok) + } + + // The disabled importer keeps dotted and undotted spellings separate. + // Normal queries still find one address and no CNAME for the primary. + dottedLegacy := policyHostsfile(t, "192.0.2.21 dotted.example dotted.example.\n", false, nil) + if rrs, ok := dottedLegacy.lookupA(dottedLegacy.getDB(), "dotted.example."); !ok || len(rrs) != 1 { + t.Fatalf("disabled dotted/undotted A=%v found=%v", rrs, ok) + } + if _, ok := dottedLegacy.lookupCNAME(dottedLegacy.getDB(), "dotted.example."); ok { + t.Fatal("disabled dotted/undotted primary acquired a CNAME") + } + + dotted := policyHostsfile(t, "192.0.2.22 dotted.example. dotted.example.\n", true, nil) + if got := len(dotted.getDB().hosts["dotted.example"].IPv4); got != 1 { + t.Fatalf("enabled dotted duplicate addresses=%d want1", got) + } + if _, ok := dotted.lookupCNAME(dotted.getDB(), "dotted.example."); ok { + t.Fatal("enabled dotted duplicate produced a self-CNAME") + } +} + +func TestHostsfilePolicyReloadAndConfigCopy(t *testing.T) { + zones := []string{"example"} + h := policyHostsfile(t, "192.0.2.1 old.example alias.example alias.example\n192.0.2.2 *.wild.example *.wild.example\n", true, zones) + zones[0] = "outside.test" + if err := os.WriteFile(h.path, []byte("192.0.2.1 bad_name outside.test\n192.0.2.3 new.example\n"), 0600); err != nil { + t.Fatal(err) + } + if err := h.load(); err != nil { + t.Fatal(err) + } + db := h.getDB() + for _, name := range []string{"old.example", "alias.example", "x.wild.example", "outside.test", "bad_name"} { + if h.hostExists(db, name) { + t.Errorf("reload retained/reintroduced %s", name) + } + } + if !h.hostExists(db, "new.example") || len(db.wildcards) != 0 || len(db.ptrs["192.0.2.1"]) != 0 || len(db.ptrs["192.0.2.2"]) != 0 { + t.Fatal("reload/copy policy failed") + } +} + +func TestHostsfilePolicyWireAndDecoded(t *testing.T) { + h := policyHostsfile(t, "192.0.2.1 outside.test Accepted.Example. Alias.Example.\n2001:db8::1 bad_name V6.Example.\n192.0.2.2 *.Wild.Example.\n", true, []string{"example"}) + for _, tc := range []struct { + name string + qtype uint16 + want string + }{ + {"Accepted.Example.", dns.TypeA, "192.0.2.1"}, {"alias.example.", dns.TypeCNAME, "accepted.example."}, {"v6.example.", dns.TypeAAAA, "2001:db8::1"}, {"1.2.0.192.in-addr.arpa.", dns.TypePTR, "accepted.example."}, {"x.wild.example.", dns.TypeA, "192.0.2.2"}, {"accepted.example.", dns.TypeAAAA, "NODATA"}, {"outside.test.", dns.TypeA, "upstream"}, {"bad_name.", dns.TypeA, "upstream"}, + } { + for _, wire := range []bool{false, true} { + passed := false + next := middleware.HandlerFunc(func(_ context.Context, ch *middleware.Chain) { + passed = true + if wire && !ch.Request.Undecoded() { + t.Error("miss decoded request") + } + ch.Cancel() + }) + w := mock.NewWriter("udp", "192.0.2.1:40000") + ch := middleware.NewChain([]middleware.Handler{h, next}) + if wire { + ch.ResetWire(w, wireHostsRequest(t, tc.name, tc.qtype)) + } else { + q := new(dns.Msg) + q.SetQuestion(tc.name, tc.qtype) + ch.Reset(w, q) + } + ch.Next(context.Background()) + got := "upstream" + if !passed { + if !w.Written() || w.Rcode() != dns.RcodeSuccess { + t.Fatalf("%s wire=%v no successful reply", tc.name, wire) + } + if wire && !ch.Request.Undecoded() { + t.Error("hit decoded request") + } + ans := w.Msg().Answer + if len(ans) == 0 { + got = "NODATA" + } else if len(ans) == 1 { + switch rr := ans[0].(type) { + case *dns.A: + got = rr.A.String() + case *dns.AAAA: + got = rr.AAAA.String() + case *dns.CNAME: + got = rr.Target + case *dns.PTR: + got = rr.Ptr + } + if _, err := w.Msg().Pack(); err != nil { + t.Fatalf("pack response: %v", err) + } + } + } + if got != tc.want { + t.Errorf("%s type=%d wire=%v got %s want %s", tc.name, tc.qtype, wire, got, tc.want) + } + } + } +} + +func TestHostsfileCheckNamesFiltersEveryToken(t *testing.T) { + h := policyHostsfile(t, "192.0.2.1 invalid*.example -bad Good.Example. bad_name Alias.Example. a..b\n192.0.2.2 * **.example café.example\n", true, nil) + db := h.getDB() + if len(db.hosts) != 2 || len(db.wildcards) != 0 { + t.Fatalf("hosts=%v wildcards=%v", db.hosts, db.wildcards) + } + rrs, ok := h.lookupCNAME(db, "alias.example") + if !ok || rrs[0].(*dns.CNAME).Target != "good.example." { + t.Fatalf("alias=%v found=%v", rrs, ok) + } + if len(db.ptrs["192.0.2.2"]) != 0 { + t.Fatal("empty surviving row created PTR") + } +} + +func TestHostsfileZoneDNSIdentity(t *testing.T) { + for _, tc := range []struct { + zone, content string + count int + }{ + {"k.example", "192.0.2.1 K.example\n192.0.2.2 K.EXAMPLE\n", 1}, + {"K.example", "192.0.2.1 K.example\n192.0.2.2 K.EXAMPLE\n", 1}, + {`corp\.example`, "192.0.2.1 corp\\.example\n192.0.2.2 child.corp\\046example.\n192.0.2.3 corp.example\n", 2}, + {`corp\.example.`, "192.0.2.1 corp\\046example.\n192.0.2.2 child.corp\\.example\n192.0.2.3 corp.example\n", 2}, + {`corp\.`, "192.0.2.1 corp\\.\n192.0.2.2 child.corp\\046.\n192.0.2.3 corp\n", 2}, + {`corp\..`, "192.0.2.1 corp\\046.\n192.0.2.2 child.corp\\.\n192.0.2.3 corp\n", 2}, + } { + t.Run(tc.zone, func(t *testing.T) { + h := policyHostsfile(t, tc.content, false, []string{tc.zone}) + if got := len(h.getDB().hosts); got != tc.count { + t.Fatalf("hosts=%v count=%d want%d", h.getDB().hosts, got, tc.count) + } + for name, entry := range h.getDB().hosts { + if name == "k.example" && entry.IPv4[0].String() != "192.0.2.2" { + t.Fatal("Unicode name folded into ASCII zone") + } + msg := new(dns.Msg) + msg.Answer = entry.aRRs + if _, err := msg.Pack(); err != nil { + t.Errorf("%q response pack: %v", name, err) + } + } + }) + } +} + +func TestHostsfileScopedWildcardLabelBoundaries(t *testing.T) { + for _, scoped := range []bool{false, true} { + var zones []string + if scoped { + zones = []string{"corp.example"} + } + h := policyHostsfile(t, "192.0.2.1 *.corp.example\n", false, zones) + for _, name := range []string{"corp.example.", "nested.child.corp.example.", `outside\.corp.example.`} { + for _, wire := range []bool{false, true} { + passed := false + next := middleware.HandlerFunc(func(_ context.Context, ch *middleware.Chain) { passed = true; ch.Cancel() }) + w := mock.NewWriter("udp", "192.0.2.1:40000") + ch := middleware.NewChain([]middleware.Handler{h, next}) + if wire { + ch.ResetWire(w, wireHostsRequest(t, name, dns.TypeA)) + } else { + q := new(dns.Msg) + q.SetQuestion(name, dns.TypeA) + ch.Reset(w, q) + } + ch.Next(context.Background()) + wantMiss := scoped && name == `outside\.corp.example.` + if passed != wantMiss || w.Written() == wantMiss { + t.Errorf("name=%s scoped=%v wire=%v passed=%v written=%v", name, scoped, wire, passed, w.Written()) + } + } + } + } + h := policyHostsfile(t, "192.0.2.1 *.corp.example\n", false, []string{"corp.example"}) + req := wireHostsRequest(t, `outside\.corp.example.`, dns.TypeA) + if n := testing.AllocsPerRun(100, func() { + var buf [dnsname.MaxPresentationLength]byte + key, ok := dnsname.AppendFoldedKey(buf[:0], req.WireName()) + if !ok { + t.Fatal("key refused") + } + if _, found := lookupKeyed(h, h.getDB(), key, dns.TypeA); found { + t.Fatal("outside wildcard hit") + } + }); n != 0 { + t.Fatalf("scoped wildcard miss allocations=%v", n) + } +} + +func TestHostsfileGenericOwnerWireIdentity(t *testing.T) { + h := policyHostsfile(t, "192.0.2.1 K.example\n192.0.2.2 corp\\046\n192.0.2.3 under_score.example\n", false, []string{"."}) + for _, name := range []string{"K.example.", `corp\..`, "under_score.example."} { + for _, wire := range []bool{false, true} { + w := mock.NewWriter("udp", "192.0.2.1:40000") + ch := middleware.NewChain([]middleware.Handler{h}) + if wire { + ch.ResetWire(w, wireHostsRequest(t, name, dns.TypeA)) + } else { + q := new(dns.Msg) + q.SetQuestion(name, dns.TypeA) + packed, err := q.Pack() + if err != nil { + t.Fatal(err) + } + if err = q.Unpack(packed); err != nil { + t.Fatal(err) + } + ch.Reset(w, q) + } + ch.Next(context.Background()) + if !w.Written() || len(w.Msg().Answer) != 1 { + t.Fatalf("%s wire=%v missing generic owner", name, wire) + } + if _, err := w.Msg().Pack(); err != nil { + t.Fatalf("%s wire=%v pack response: %v", name, wire, err) + } + } + } +} + +func TestHostsfilePolicyDuplicateResponseCounts(t *testing.T) { + h := policyHostsfile(t, "192.0.2.40 primary.example. PRIMARY.EXAMPLE alias.example ALIAS.EXAMPLE. alias.example\n2001:db8::40 primary.example PRIMARY.EXAMPLE v6alias.example V6ALIAS.EXAMPLE.\n", true, []string{"example"}) + for _, tc := range []struct { + name string + qtype uint16 + count int + kind string + want string + }{ + {"primary.example.", dns.TypeA, 1, "A", "192.0.2.40"}, + {"primary.example.", dns.TypeAAAA, 1, "AAAA", "2001:db8::40"}, + {"alias.example.", dns.TypeCNAME, 1, "CNAME", "primary.example."}, + {"v6alias.example.", dns.TypeCNAME, 1, "CNAME", "primary.example."}, + {reverseName("192.0.2.40"), dns.TypePTR, 1, "PTR", "primary.example."}, + {reverseName("2001:db8::40"), dns.TypePTR, 1, "PTR", "primary.example."}, + {"primary.example.", dns.TypeCNAME, 0, "", ""}, + {"missing.example.", dns.TypeA, 0, "", ""}, + } { + for _, wire := range []bool{false, true} { + passed := false + next := middleware.HandlerFunc(func(_ context.Context, ch *middleware.Chain) { passed = true; ch.Cancel() }) + w := mock.NewWriter("udp", "192.0.2.1:40000") + ch := middleware.NewChain([]middleware.Handler{h, next}) + if wire { + ch.ResetWire(w, wireHostsRequest(t, tc.name, tc.qtype)) + } else { + q := new(dns.Msg) + q.SetQuestion(tc.name, tc.qtype) + ch.Reset(w, q) + } + ch.Next(context.Background()) + missing := tc.count == 0 + if passed != missing || w.Written() == missing { + t.Fatalf("%s type=%d wire=%v passed=%v written=%v want missing=%v", tc.name, tc.qtype, wire, passed, w.Written(), missing) + } + if missing { + continue + } + if !w.Msg().Authoritative { + t.Fatalf("%s type=%d wire=%v did not receive local authoritative answer", tc.name, tc.qtype, wire) + } + if got := len(w.Msg().Answer); got != tc.count { + t.Errorf("%s type=%d wire=%v answer count=%d want%d", tc.name, tc.qtype, wire, got, tc.count) + } + for _, rr := range w.Msg().Answer { + if dns.TypeToString[rr.Header().Rrtype] != tc.kind { + t.Errorf("%s type=%d wire=%v answer=%v want %s", tc.name, tc.qtype, wire, rr, tc.kind) + } + got := "" + switch rr := rr.(type) { + case *dns.A: + got = rr.A.String() + case *dns.AAAA: + got = rr.AAAA.String() + case *dns.CNAME: + got = rr.Target + case *dns.PTR: + got = rr.Ptr + } + if got != tc.want { + t.Errorf("%s type=%d wire=%v answer value=%q want %q", tc.name, tc.qtype, wire, got, tc.want) + } + } + } + } +} + +func TestHostsfilePolicyMixedHomeFile(t *testing.T) { + content := "192.0.2.1 router.home.arpa router.home.arpa.\n" + + "192.0.2.2 harmonyhub.home.arpa home.home.arpa ns.home.arpa alias.home.arpa\n" + + "127.0.0.1 localhost.localdomain localhost vyos\n" + + "127.0.0.1 dns1.nextdns.io\n" + + "192.0.2.4 host-.home.arpa\n" + + "192.0.2.11 -.home.arpa alias-.home.arpa\n" + + "192.0.2.12 -.home.arpa alias-.home.arpa\n" + + "192.0.2.13 -.home.arpa alias-.home.arpa\n" + h := policyHostsfile(t, content, true, []string{"home.arpa"}) + for _, tc := range []struct { + name string + qtype uint16 + want string + miss bool + }{ + {"router.home.arpa.", dns.TypeA, "192.0.2.1", false}, + {"harmonyhub.home.arpa.", dns.TypeA, "192.0.2.2", false}, + {"alias.home.arpa.", dns.TypeCNAME, "harmonyhub.home.arpa.", false}, + {"2.2.0.192.in-addr.arpa.", dns.TypePTR, "harmonyhub.home.arpa.", false}, + {"localhost.", dns.TypeA, "upstream", true}, + {"localhost.localdomain.", dns.TypeA, "upstream", true}, + {"vyos.", dns.TypeA, "upstream", true}, + {"dns1.nextdns.io.", dns.TypeA, "upstream", true}, + {"host-.home.arpa.", dns.TypeA, "upstream", true}, + {"-.home.arpa.", dns.TypeA, "upstream", true}, + {"alias-.home.arpa.", dns.TypeCNAME, "upstream", true}, + {reverseName("192.0.2.11"), dns.TypePTR, "upstream", true}, + {reverseName("192.0.2.12"), dns.TypePTR, "upstream", true}, + {reverseName("192.0.2.13"), dns.TypePTR, "upstream", true}, + {reverseName("127.0.0.1"), dns.TypePTR, "upstream", true}, + } { + for _, wire := range []bool{false, true} { + passed := false + next := middleware.HandlerFunc(func(_ context.Context, ch *middleware.Chain) { passed = true; ch.Cancel() }) + w := mock.NewWriter("udp", "192.0.2.1:40000") + ch := middleware.NewChain([]middleware.Handler{h, next}) + if wire { + ch.ResetWire(w, wireHostsRequest(t, tc.name, tc.qtype)) + } else { + q := new(dns.Msg) + q.SetQuestion(tc.name, tc.qtype) + ch.Reset(w, q) + } + ch.Next(context.Background()) + if passed != tc.miss { + t.Errorf("%s type=%d wire=%v passed=%v want fallthrough=%v", tc.name, tc.qtype, wire, passed, tc.miss) + } + got := "upstream" + if tc.miss { + if w.Written() { + t.Errorf("%s type=%d wire=%v fallthrough unexpectedly wrote a response", tc.name, tc.qtype, wire) + } + } else { + if !w.Written() || !w.Msg().Authoritative { + t.Fatalf("%s type=%d wire=%v local response not authoritative", tc.name, tc.qtype, wire) + } + if len(w.Msg().Answer) != 1 { + got = fmt.Sprintf("%d answers", len(w.Msg().Answer)) + } else { + switch rr := w.Msg().Answer[0].(type) { + case *dns.A: + got = rr.A.String() + case *dns.CNAME: + got = rr.Target + case *dns.PTR: + got = rr.Ptr + } + } + } + if got != tc.want { + t.Errorf("%s type=%d wire=%v got %s want %s", tc.name, tc.qtype, wire, got, tc.want) + } + } + } +} + +func TestHostsfileZonesConfigLoadWireIdentity(t *testing.T) { + labelLengths := [...]byte{3, 3, 4, 4} + for _, prefix := range []string{"", "child."} { + for _, spelling := range []string{"ü", `\195\188`} { + for n := 1; n <= 4; n++ { + zone := prefix + spelling + strings.Repeat(`\`, n) + "." + t.Run(fmt.Sprintf("prefix=%q/spelling=%q/backslashes=%d", prefix, spelling, n), func(t *testing.T) { + // Use the other spelling in the file to pin DNS byte identity + // across raw UTF-8 and decimal-escaped zone settings. + ownerSpelling := `\195\188` + if spelling != "ü" { + ownerSpelling = "ü" + } + owner := prefix + ownerSpelling + strings.Repeat(`\`, n) + "." + hosts := fmt.Sprintf("192.0.2.31 %s\n192.0.2.32 child.%s\n192.0.2.33 different.test\n", owner, owner) + hostsPath := createTempHostsFile(t, hosts) + configPath := filepath.Join(t.TempDir(), "sdns.toml") + text := fmt.Sprintf("version = %q\nipv6access = true\ndnssec = %q\nrootservers = [%q]\nhostsfile = %q\nhostsfilechecknames = false\nhostsfilezones = [%q]\ndirectory = %q\n", "1.9.0", "off", "192.0.2.53:53", hostsPath, zone, t.TempDir()) + if err := os.WriteFile(configPath, []byte(text), 0600); err != nil { + t.Fatal(err) + } + cfg, err := config.Load(configPath, "1.9.0") + if err != nil { + t.Fatalf("config.Load rejected %q: %v", zone, err) + } + h := New(cfg) + if h == nil { + t.Fatalf("New rejected loaded zone %q", zone) + } + if h.watcher != nil { + if err := h.watcher.Close(); err != nil { + t.Fatal(err) + } + } + + // Construct wire bytes independently. The escaped final dot + // must stay in its label, not become the root or truncate it. + label := append([]byte{0xc3, 0xbc}, bytes.Repeat([]byte{92}, n/2)...) + if n%2 != 0 { + label = append(label, 46) + } + zoneWire := append([]byte{labelLengths[n-1]}, label...) + zoneWire = append(zoneWire, 0) + if prefix != "" { + zoneWire = append([]byte{5, 99, 104, 105, 108, 100}, zoneWire...) + } + assertConfigZoneHosts(t, h, zoneWire) + }) + } + } + } +} + +func assertConfigZoneHosts(t *testing.T, h *Hostsfile, zoneWire []byte) { + t.Helper() + var keyBuf [dnsname.MaxPresentationLength]byte + zoneKey, ok := dnsname.AppendFoldedKey(keyBuf[:0], zoneWire) + if !ok { + t.Fatal("expected zone wire name refused") + } + if len(h.zones) != 1 || h.zones[0] != string(zoneKey)+"." { + t.Fatalf("loaded zones=%q want wire key %q", h.zones, string(zoneKey)+".") + } + if h.zones[0] == "." { + t.Fatal("non-root configured zone collapsed to unrestricted root") + } + childWire := append([]byte{5}, []byte("child")...) + childWire = append(childWire, zoneWire...) + var childBuf [dnsname.MaxPresentationLength]byte + childKey, ok := dnsname.AppendFoldedKey(childBuf[:0], childWire) + if !ok { + t.Fatal("expected child wire name refused") + } + db := h.getDB() + if db.hosts[string(zoneKey)] == nil || db.hosts[string(childKey)] == nil || db.hosts["different.test"] != nil { + t.Fatalf("hosts do not match zone wire identity: keys=%v", db.hosts) + } +} + +func TestHostsfileRejectsStructurallyInvalidConfiguredZone(t *testing.T) { + if h := New(&config.Config{HostsFile: createTempHostsFile(t, "192.0.2.1 valid.example\n"), HostsFileZones: []string{`invalid\`}}); h != nil { + if h.watcher != nil { + _ = h.watcher.Close() + } + t.Fatal("trailing escape zone was accepted") + } +}