From 78def9fc46a14aba37c8e43742c1618c57ea7a09 Mon Sep 17 00:00:00 2001 From: Max <109485367+hawkff@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:53:34 -0400 Subject: [PATCH] feat(lists): parse bracketed IPv6 literals and map IDNs like DNS clients (#2261) --- cache/stringcache/list_scope_test.go | 91 +++++++++++ .../testdata/golden/edge_cases.golden | 2 +- .../testdata/golden/edge_cases.txt | 10 ++ lists/parsers/hosts.go | 146 +++++++++++++++--- lists/parsers/hosts_funcs_test.go | 101 ++++++++---- lists/parsers/hosts_test.go | 106 ++++++++++++- .../FuzzHostsUnmarshalText/b421722db68f7e5c | 2 + resolver/hosts_file_resolver_test.go | 22 +++ 8 files changed, 427 insertions(+), 53 deletions(-) create mode 100644 cache/stringcache/list_scope_test.go create mode 100644 lists/parsers/testdata/fuzz/FuzzHostsUnmarshalText/b421722db68f7e5c diff --git a/cache/stringcache/list_scope_test.go b/cache/stringcache/list_scope_test.go new file mode 100644 index 00000000..f8428b10 --- /dev/null +++ b/cache/stringcache/list_scope_test.go @@ -0,0 +1,91 @@ +package stringcache + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/0xERR0R/blocky/lists/parsers" +) + +// TestListEntriesCannotWidenScope runs list lines through the production parser +// and cache chain and checks that malformed entries are rejected instead of +// being cached as a broader rule. "*.com.xn--" once normalized to "*.com.", +// which the wildcard cache folded into a rule matching every .com name, and no +// parse error was raised for it, so the error allowance never saw it. +func TestListEntriesCannotWidenScope(t *testing.T) { + const group = "g" + + malformed := []string{ + "*.com.xn--", + "*.com.\u00ad", // soft hyphen only: an ignored code point + "*.xn--.com", + "*.\u200b.com", // zero-width space only + "*.com\u3002xn--", // ideographic full stop as the separator + "*..com", + "*.", + "com.xn--", + "0.0.0.0 com.xn--", + } + + legitimate := []string{ + "*.example.org.", + "*.münchen.example.de", + "münchen.example.de", + // root dots: queries reach the cache without them + "rootdot.example.net.", + "bücher.example.de.", + "0.0.0.0 hosts.example.net.", + } + + lines := strings.Join(append(append([]string{}, malformed...), legitimate...), "\n") + "\n" + + var errs []string + + p := parsers.AllowErrors(parsers.Hosts(strings.NewReader(lines)), parsers.NoErrorLimit) + p.OnErr(func(err error) { errs = append(errs, err.Error()) }) + + chain := NewChainedGroupedCache( + NewInMemoryGroupedRegexCache(), NewInMemoryGroupedWildcardCache(), NewInMemoryGroupedStringCache(), + ) + factory := chain.Refresh(group) + + err := parsers.ForEach(context.Background(), p, func(entry *parsers.HostsIterator) error { + return entry.ForEach(func(host string) error { + factory.AddEntry(host) + + return nil + }) + }) + if err != nil { + t.Fatal(err) + } + + factory.Finish() + + if len(errs) != len(malformed) { + t.Fatalf("expected %d parse errors, got %d:\n%s", len(malformed), len(errs), strings.Join(errs, "\n")) + } + + for i, e := range errs { + if want := fmt.Sprintf("line %d: ", i+1); !strings.HasPrefix(e, want) { + t.Errorf("error %d does not carry its line position: %s", i, e) + } + } + + for _, name := range []string{"example.com", "com", "sub.example.com"} { + if got := chain.Contains(name, []string{group}); len(got) != 0 { + t.Errorf("%q matched %v, a malformed entry widened the rules", name, got) + } + } + + for _, name := range []string{ + "sub.example.org", "sub.xn--mnchen-3ya.example.de", "xn--mnchen-3ya.example.de", + "rootdot.example.net", "xn--bcher-kva.example.de", "hosts.example.net", + } { + if got := chain.Contains(name, []string{group}); len(got) == 0 { + t.Errorf("%q did not match, a legitimate entry was lost", name) + } + } +} diff --git a/cache/stringcache/testdata/golden/edge_cases.golden b/cache/stringcache/testdata/golden/edge_cases.golden index 10f7a31c..89633536 100644 --- a/cache/stringcache/testdata/golden/edge_cases.golden +++ b/cache/stringcache/testdata/golden/edge_cases.golden @@ -21,7 +21,7 @@ string spaced-eol.example.com string sub.domain.with-hyphen.example.com string tab-separated.example.com string trailing-hyphen-.example.com -string trailing.dot.example.com. +string trailing.dot.example.com string under_score.example.org string xn--bcher-kva.example.com string xn--mnchen-3ya.example.de diff --git a/cache/stringcache/testdata/golden/edge_cases.txt b/cache/stringcache/testdata/golden/edge_cases.txt index 9d4a9062..b4b4f517 100644 --- a/cache/stringcache/testdata/golden/edge_cases.txt +++ b/cache/stringcache/testdata/golden/edge_cases.txt @@ -61,3 +61,13 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com # --- leading/trailing hyphen labels (allowed by blocky's relaxed validation) --- -leading-hyphen.example.com trailing-hyphen-.example.com + +# --- rejected: a label that vanishes in IDNA mapping would widen the rule --- +# (empty "xn--" payload; the second line ends in a soft hyphen, U+00AD; +# the third uses an ideographic full stop, U+3002, as the separator) +*.com.xn-- +*.com.­ +*.com。xn-- +com.xn-- +*..com +*. diff --git a/lists/parsers/hosts.go b/lists/parsers/hosts.go index dd594f57..ef28f710 100644 --- a/lists/parsers/hosts.go +++ b/lists/parsers/hosts.go @@ -20,6 +20,16 @@ const ( maxDNSLabelLength = 63 // https://www.rfc-editor.org/rfc/rfc1034#section-3.1 ) +// idnaProfile maps an entry to the form DNS clients put on the wire: the UTS #46 +// lookup mapping (case folding, NFC, width folding, ignored code points dropped), +// then punycode encoding of non-ASCII labels. Malformed punycode labels fail. +// The STD3 and label validity checks are off: they reject entries for reasons +// that amount to "that domain should not be used", and that decision belongs +// to the list. +// +//nolint:gochecknoglobals +var idnaProfile = idna.New(idna.MapForLookup(), idna.StrictDomainName(false), idna.ValidateLabels(false)) + // Hosts parses `r` as a series of `HostsIterator`. // It supports both the hosts file and host list formats. // @@ -170,13 +180,16 @@ func (e *HostsFileEntry) UnmarshalText(data []byte) error { hosts := make([]string, 0, len(fields)-1) // there must be at least one host for the line to be valid for _, field := range fields[1:] { - host := string(field) + host, err := toASCII(string(field)) + if err != nil { + return err + } if err := validateDomainName(host); err != nil { return err } - hosts = append(hosts, host) + hosts = append(hosts, trimRootDot(host)) } if len(hosts) == 0 { @@ -231,7 +244,18 @@ func (e *WildcardEntry) UnmarshalText(data []byte) error { return fmt.Errorf("unsupported wildcard '%s': must start with '*.' and contain no other '*'", entry) } - *e = WildcardEntry(entry) + entry, err := toASCII(entry) + if err != nil { + return err + } + + // The cache strips the "*." and any surrounding dots, so an empty label here + // widens the rule: "*..com" would match every .com name. + if err := validateDomainName(entry[len("*."):]); err != nil { + return err + } + + *e = WildcardEntry(trimRootDot(entry)) return nil } @@ -241,28 +265,21 @@ func (e WildcardEntry) forEachHost(callback func(string) error) error { } func normalizeHostsListEntry(host string) (string, error) { - // Lookup is the profile preferred for DNS queries, we use Punycode here as it does less validation. - // That avoids rejecting domains in a list for reasons that amount to "that domain should not be used" - // since the goal of the list is to determine whether the domain should be used or not, we leave - // that decision to it. - idnaProfile := idna.Punycode - // remove optional start and end markers for ABP styled lists host = strings.TrimPrefix(host, "||") host = strings.TrimSuffix(host, "^") - // IDNA is only needed for entries that contain non-ASCII (Unicode) characters, - // or an "xn--" ACE prefix (which IDNA validates even when the input is already - // ASCII). For all other (pure-ASCII) entries the ToUnicode/ToASCII dance leaves - // the host unchanged, so skip it — IDNA is comparatively expensive and the vast - // majority of list entries are plain ASCII. - if !isRegex(host) && needsIDNA(host) { - hostUnicode, err := idnaProfile.ToUnicode(host) - if err != nil || hostUnicode == host { - host, err = idnaProfile.ToASCII(host) - if err != nil { - return "", fmt.Errorf("%w: %s", err, host) - } + // URL-style IPv6 literal, as in "||[2001:db8::1]^" + if ip, ok := unwrapIPv6Literal(host); ok { + return ip, nil + } + + if !isRegex(host) { + var err error + + host, err = toASCII(host) + if err != nil { + return "", err } } @@ -270,14 +287,95 @@ func normalizeHostsListEntry(host string) (string, error) { return "", err } - return host, nil + return trimRootDot(host), nil +} + +// trimRootDot drops the trailing root dot of a validated entry. Lookups strip +// it from the query name (util.ExtractDomain), so a cache key or hosts file +// name stored as "example.com." never matches. Callers validate first, which +// keeps "example.com.." rejected. +func trimRootDot(host string) string { + return strings.TrimSuffix(host, ".") +} + +// unwrapIPv6Literal strips the brackets of a URL-style IPv6 literal such as +// "[2001:db8::1]". Brackets only wrap IPv6 addresses (RFC 3986), so the domain +// name validation rejects anything else between them. +func unwrapIPv6Literal(s string) (string, bool) { + if len(s) < 2 || s[0] != '[' || s[len(s)-1] != ']' { + return "", false + } + + ip := s[1 : len(s)-1] + if !strings.Contains(ip, ":") || net.ParseIP(ip) == nil { + return "", false + } + + return ip, true +} + +// toASCII returns host in the ASCII (A-label) form that DNS queries carry, so +// an entry matches queries however the list spelled it. It keeps ASCII case: +// the caches fold it, and error messages echo the entry as written. +// +// Only entries with non-ASCII (Unicode) characters or an "xn--" ACE prefix +// (which IDNA validates even in ASCII input) need IDNA. Pure-ASCII entries come +// back as is: IDNA is slow next to a byte scan, and most list entries are plain +// ASCII. +func toASCII(host string) (string, error) { + if !needsIDNA(host) { + return host, nil + } + + return idnaToASCII(host) +} + +// idnaToASCII maps host with idnaProfile and rejects it when a label vanishes +// in the mapping. An empty "xn--" payload decodes to nothing, and a label made +// only of ignored code points (soft hyphen, zero-width space) maps to nothing; +// either turns "*.com.xn--" into "*.com.", which the cache widens to every +// .com name. The mapping never removes a label separator, so a drop in the +// number of non-empty labels is the sign of a vanished label. A trailing root +// dot in the input stays legitimate; the entry types drop it after validation. +func idnaToASCII(host string) (string, error) { + ascii, err := idnaProfile.ToASCII(host) + if err != nil { + return "", fmt.Errorf("%w: %s", err, host) + } + + if nonEmptyLabels(ascii) < nonEmptyLabels(host) { + return "", fmt.Errorf("label vanishes in IDNA mapping: %s", host) + } + + return ascii, nil +} + +// nonEmptyLabels counts the labels of s that have content. Besides '.', the +// separators are the ideographic and fullwidth full stops that UTS #46 maps to +// '.', so the count of an entry and of its mapping line up. +func nonEmptyLabels(s string) int { + n, inLabel := 0, false + + for _, r := range s { + switch r { + case '.', '\u3002', '\uff0e', '\uff61': + inLabel = false + default: + if !inLabel { + n++ + inLabel = true + } + } + } + + return n } // needsIDNA reports whether host requires IDNA processing: any non-ASCII byte // needs it, and so does an "xn--" ACE prefix (matched case-insensitively and // conservatively anywhere in the string), since IDNA validates punycode labels -// even for ASCII input. Pure-ASCII input without an ACE prefix is left unchanged -// by IDNA, so it can safely skip it. +// even for ASCII input. Pure-ASCII input without an ACE prefix differs from its +// IDNA mapping by ASCII case alone, which the caches fold, so it skips IDNA. func needsIDNA(host string) bool { for i := range len(host) { c := host[i] diff --git a/lists/parsers/hosts_funcs_test.go b/lists/parsers/hosts_funcs_test.go index 14244c21..65d435ab 100644 --- a/lists/parsers/hosts_funcs_test.go +++ b/lists/parsers/hosts_funcs_test.go @@ -1,13 +1,11 @@ package parsers import ( - "fmt" + "bytes" "net" "regexp" "strings" "testing" - - "golang.org/x/net/idna" ) // Plain table + fuzz tests for the helper functions in hosts.go. They live in a @@ -61,26 +59,25 @@ func FuzzIsValidDomainName(f *testing.F) { // --- entry normalization (normalizeHostsListEntry) --- -// normalizeReference is a verbatim copy of normalizeHostsListEntry as it was -// before the ASCII fast-path was added. The fast-path must be equivalent to it -// for every input (both the returned host and whether an error occurred). +// normalizeReference is normalizeHostsListEntry without the ASCII fast-path: +// every non-regex entry goes through idnaToASCII. The fast-path must be +// equivalent to it for every input: same error presence, and the same host up +// to ASCII case (the IDNA mapping lowercases, the fast-path leaves case to the +// caches, which fold it). func normalizeReference(host string) (string, error) { - var err error - - var hostUnicode string - - idnaProfile := idna.Punycode - host = strings.TrimPrefix(host, "||") host = strings.TrimSuffix(host, "^") + if ip, ok := unwrapIPv6Literal(host); ok { + return ip, nil + } + if !isRegex(host) { - hostUnicode, err = idnaProfile.ToUnicode(host) - if err != nil || hostUnicode == host { - host, err = idnaProfile.ToASCII(host) - if err != nil { - return "", fmt.Errorf("%w: %s", err, host) - } + var err error + + host, err = idnaToASCII(host) + if err != nil { + return "", err } } @@ -88,7 +85,7 @@ func normalizeReference(host string) (string, error) { return "", err } - return host, nil + return trimRootDot(host), nil } func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) { @@ -96,28 +93,38 @@ func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) { // pure ASCII (fast-path skips IDNA) "example.com", "Example.COM", "under_score.example.org", "trailing.dot.example.com.", "-leading.example.com", - "a.b.c", "single", "192.168.1.1", + "a.b.c", "single", "192.168.1.1", "DEAD::BEEF", + // IPv6 literals + "[2001:db8::1]", "||[::1]^", "[::ffff:1.2.3.4]", + "[1.2.3.4]", "[example.com]", "[::1", "::1]", "[]", "[", "[fe80::1%eth0]", "[::1]:53", // ABP markers "||abp.example.com^", "||abp.example.org", // regex (IDNA always skipped) - "/^ads\\.example\\.com$/", "/café/", + "/^ads\\.example\\.com$/", "/café/", "/[A-Z]+/", // already-encoded punycode (ASCII) "xn--mnchen-3ya.example.de", "xn--bcher-kva.example.com", - "xn--invalid!!.example.com", + "XN--MNCHEN-3YA.example.de", "xn--invalid!!.example.com", "xn--", // Unicode (must go through IDNA) "münchen.example.de", "bücher.example.com", "пример.example.com", "日本語.example.jp", "café.example.fr", + "MÜNCHEN.example.de", "u\u0308ber.example.de", "example.com", "ẞ.example.de", // mixed punycode + Unicode (the subtle case) "xn--mnchen-3ya.café.com", "café.xn--mnchen-3ya.com", // Unicode with ABP markers "||münchen.example.de^", + // trailing root dots and dot-like runes + "example.com.", "münchen.de.", "example。com", "example.com。", + // junk that must stay rejected + "dGVzdA==", "YWJj+/==", "münchen..de", "\xff.example.com", + // labels that vanish in the mapping + "com.xn--", "xn--", "xn--.com", "com.\u00ad", "\ufeff", "a.\u200b.b", "com\u3002xn--", "com\uff0e\u00ad", } for _, in := range inputs { gotHost, gotErr := normalizeHostsListEntry(in) wantHost, wantErr := normalizeReference(in) - if gotHost != wantHost || (gotErr == nil) != (wantErr == nil) { + if !strings.EqualFold(gotHost, wantHost) || (gotErr == nil) != (wantErr == nil) { t.Errorf("normalizeHostsListEntry(%q) = (%q, err=%v); reference = (%q, err=%v)", in, gotHost, gotErr, wantHost, wantErr) } @@ -125,9 +132,12 @@ func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) { } // FuzzNormalizeHostsListEntry checks the fast-path matches the reference for -// arbitrary input (host value and error presence). +// arbitrary input (host value up to ASCII case, and error presence). func FuzzNormalizeHostsListEntry(f *testing.F) { - for _, s := range []string{"example.com", "münchen.de", "xn--mnchen-3ya.de", "xn--a.café.com", "||x^", "/r/"} { + for _, s := range []string{ + "example.com", "Example.COM", "münchen.de", "MÜNCHEN.de", "xn--mnchen-3ya.de", "xn--a.café.com", + "||x^", "/r/", "[::1]", "||[2001:db8::1]^", "[x]", "com.xn--", "com.\u00ad", "example\u3002com", + } { f.Add(s) } @@ -135,7 +145,7 @@ func FuzzNormalizeHostsListEntry(f *testing.F) { gotHost, gotErr := normalizeHostsListEntry(in) wantHost, wantErr := normalizeReference(in) - if gotHost != wantHost || (gotErr == nil) != (wantErr == nil) { + if !strings.EqualFold(gotHost, wantHost) || (gotErr == nil) != (wantErr == nil) { t.Errorf("normalizeHostsListEntry(%q) = (%q, err=%v); reference = (%q, err=%v)", in, gotHost, gotErr, wantHost, wantErr) } @@ -185,7 +195,11 @@ func FuzzMightBeIP(f *testing.F) { // document: // - a successful HostsFileEntry has a non-nil IP and only valid host names; // - HostListEntry normalization is idempotent: re-parsing its own output is a -// fixpoint, since a normalized entry is already in canonical form. +// fixpoint, since a normalized entry is already in canonical form; +// - no label vanishes: an accepted host has at least as many non-empty labels +// as the field it came from. Dropping one ("*.com.xn--" to "*.com.") widens +// a wildcard, and the cache folds the trailing dot away; +// - no accepted host ends in a root dot, since lookups never carry one. func FuzzHostsUnmarshalText(f *testing.F) { for _, s := range []string{ "example.com", @@ -195,9 +209,14 @@ func FuzzHostsUnmarshalText(f *testing.F) { "::1 ip6-localhost", "fe80::1%eth0 router.local", "*.tracker.example.com", + "*.münchen.example.de", `/^ads\.example\.com$/`, "münchen.example.de", + "MÜNCHEN.example.de", "xn--mnchen-3ya.example.de", + "0.0.0.0 münchen.example.de", + "[2001:db8::1]", + "*.com.xn--", "*.com.\u00ad", "*.com\u3002xn--", "0.0.0.0 com.xn--", "example.com.", "", " ", "\t", "1.2.3.4", } { f.Add(s) @@ -205,6 +224,19 @@ func FuzzHostsUnmarshalText(f *testing.F) { f.Fuzz(func(t *testing.T, in string) { data := []byte(in) + fields := bytes.Fields(data) + + // noVanishedLabel fails the test when host has fewer non-empty labels than + // the input field it was parsed from, or still carries a root dot. + noVanishedLabel := func(kind string, field []byte, host string) { + if nonEmptyLabels(host) < nonEmptyLabels(string(field)) { + t.Fatalf("%s parsed %q into %q, dropping a label", kind, field, host) + } + + if strings.HasSuffix(host, ".") { + t.Fatalf("%s parsed %q into %q, keeping the root dot", kind, field, host) + } + } // None of the parsers may panic, regardless of whether they accept the input. var list HostListEntry @@ -213,6 +245,9 @@ func FuzzHostsUnmarshalText(f *testing.F) { var file HostsFileEntry fileErr := file.UnmarshalText(data) + var wildcard WildcardEntry + wildcardErr := wildcard.UnmarshalText(data) + var iter HostsIterator _ = iter.UnmarshalText(data) @@ -222,17 +257,29 @@ func FuzzHostsUnmarshalText(f *testing.F) { t.Fatalf("HostsFileEntry parsed %q but IP is nil", in) } + i := 0 + _ = file.forEachHost(func(host string) error { if err := validateDomainName(host); err != nil { t.Fatalf("HostsFileEntry parsed %q but emitted host %q is invalid: %v", in, host, err) } + i++ + noVanishedLabel("HostsFileEntry", fields[i], host) + return nil }) } + if wildcardErr == nil { + noVanishedLabel("WildcardEntry", fields[0], wildcard.String()) + } + // Normalizing an already-normalized host-list entry must be a fixpoint. if listErr == nil { + abpTrimmed := bytes.TrimSuffix(bytes.TrimPrefix(fields[0], []byte("||")), []byte("^")) + noVanishedLabel("HostListEntry", abpTrimmed, list.String()) + var reparsed HostListEntry if err := reparsed.UnmarshalText([]byte(list.String())); err != nil { t.Fatalf("HostListEntry %q normalized to %q which fails to re-parse: %v", in, list.String(), err) diff --git a/lists/parsers/hosts_test.go b/lists/parsers/hosts_test.go index 7dc14039..fdbb2a86 100644 --- a/lists/parsers/hosts_test.go +++ b/lists/parsers/hosts_test.go @@ -96,24 +96,128 @@ var _ = Describe("Hosts", func() { }) }) + When("parsing entries that need normalization", func() { + It("emits the form DNS queries carry", func() { + cases := []struct { + line string + hosts []string + }{ + // URL-style IPv6 literals + {"[2001:db8::1]", []string{"2001:db8::1"}}, + {"||[::1]^", []string{"::1"}}, + // Unicode, punycode and mixed labels normalize to lowercase A-labels + {"MÜNCHEN.example.de", []string{"xn--mnchen-3ya.example.de"}}, + {"XN--MNCHEN-3YA.example.de", []string{"xn--mnchen-3ya.example.de"}}, + {"xn--mnchen-3ya.café.com", []string{"xn--mnchen-3ya.xn--caf-dma.com"}}, + {"имяенн.010.xn--p1acf", []string{"xn--e1afmfa9h.010.xn--p1acf"}}, + // the same applies to hosts file names and wildcards + {"0.0.0.0 münchen.example.de bücher.example.de", []string{"xn--mnchen-3ya.example.de", "xn--bcher-kva.example.de"}}, + {"*.münchen.example.de", []string{"*.xn--mnchen-3ya.example.de"}}, + // the parser drops trailing root dots: lookups strip them from the query + {"example.com.", []string{"example.com"}}, + {"münchen.example.de.", []string{"xn--mnchen-3ya.example.de"}}, + {"*.münchen.example.de.", []string{"*.xn--mnchen-3ya.example.de"}}, + {"0.0.0.0 münchen.example.de. bücher.example.de.", []string{"xn--mnchen-3ya.example.de", "xn--bcher-kva.example.de"}}, + {"example\u3002com\u3002", []string{"example.com"}}, + // regexes stay as written + {"/[A-Z]+\\.café/", []string{"/[A-Z]+\\.café/"}}, + } + + for _, c := range cases { + sut := Hosts(strings.NewReader(c.line)) + + it, err := sut.Next(context.Background()) + Expect(err).Should(Succeed(), c.line) + Expect(iteratorToList(it.ForEach)).Should(Equal(c.hosts), c.line) + } + }) + }) + When("parsing invalid lines", func() { It("fails", func() { lines := []string{ "invalidIP localhost", "xn---mllerk1va.com", + "0.0.0.0 xn---mllerk1va.com", + "*.xn---mllerk1va.com", `/invalid regex ??/`, "invalid.*.wildcard", + // brackets are only valid around an IPv6 address + "[example.com]", + "[1.2.3.4]", + "[fe80::1%eth0]", + "[::1]:53", + "[::1", + // Base64-like junk and malformed IDNs stay rejected, not repaired into host names + "dGVzdA==", + "YWJj+/==", + "münchen..example.de", + "münchen/example.de", + // labels that vanish in the IDNA mapping (empty "xn--" payload, ignored + // code points only) would leave a trailing dot the cache folds away, + // turning "*.com.xn--" into a rule for every .com name + "xn--", + "com.xn--", + "0.0.0.0 com.xn--", + "*.com.xn--", + "*.xn--.com", + "*.com.\u00ad", + "*.\u200b.com", + "*.com\u3002xn--", // ideographic full stop as the separator + "com\uff0e\u00ad", // fullwidth full stop + "\ufeff", + // wildcard suffixes are validated like plain entries + "*.", + "*..com", + "*.com/path", + // the grammar allows one root dot, so dropping it cannot leave another behind + "example.com..", + "0.0.0.0 example.com..", + "*.example.com..", } for _, line := range lines { sut := Hosts(strings.NewReader(line)) _, err := sut.Next(context.Background()) - Expect(err).Should(HaveOccurred()) + Expect(err).Should(HaveOccurred(), line) Expect(IsNonResumableErr(err)).ShouldNot(BeTrue()) Expect(sut.Position()).Should(Equal("line 1")) } }) + + It("reports them with their position and counts them against the error limit", func() { + sut := AllowErrors(Hosts(linesReader( + "[2001:db8::1]", + "dGVzdA==", + "MÜNCHEN.example.de", + "YWJj+/==", + "valid.example.com", + )), 1) + + var errs []string + + sut.OnErr(func(err error) { + errs = append(errs, err.Error()) + }) + + it, err := sut.Next(context.Background()) + Expect(err).Should(Succeed()) + Expect(iteratorToList(it.ForEach)).Should(Equal([]string{"2001:db8::1"})) + + it, err = sut.Next(context.Background()) + Expect(err).Should(Succeed()) + Expect(iteratorToList(it.ForEach)).Should(Equal([]string{"xn--mnchen-3ya.example.de"})) + Expect(sut.Position()).Should(Equal("line 3")) + + _, err = sut.Next(context.Background()) + Expect(err).Should(MatchError(ErrTooManyErrors)) + Expect(sut.Position()).Should(Equal("line 4")) + + Expect(errs).Should(HaveLen(2)) + Expect(errs[0]).Should(SatisfyAll(HavePrefix("line 2: "), ContainSubstring("invalid domain name: dGVzdA=="))) + Expect(errs[1]).Should(SatisfyAll(HavePrefix("line 4: "), ContainSubstring("invalid domain name: YWJj+/=="))) + }) }) Describe("HostsIterator.ForEachHost", func() { diff --git a/lists/parsers/testdata/fuzz/FuzzHostsUnmarshalText/b421722db68f7e5c b/lists/parsers/testdata/fuzz/FuzzHostsUnmarshalText/b421722db68f7e5c new file mode 100644 index 00000000..375b9764 --- /dev/null +++ b/lists/parsers/testdata/fuzz/FuzzHostsUnmarshalText/b421722db68f7e5c @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0.^") diff --git a/resolver/hosts_file_resolver_test.go b/resolver/hosts_file_resolver_test.go index 2f92eb86..8c4d3487 100644 --- a/resolver/hosts_file_resolver_test.go +++ b/resolver/hosts_file_resolver_test.go @@ -151,6 +151,28 @@ var _ = Describe("HostsFileResolver", func() { }) }) + When("names carry a root dot or need IDNA mapping", func() { + BeforeEach(func() { + tmpFile = tmpDir.CreateStringFile("hosts-root-dot.txt", + "10.0.0.5 rootdot.example.de. münchen.example.de.", + ) + + sutConfig.Sources = config.NewBytesSources(tmpFile.Path) + }) + + It("resolves them the way clients query them", func() { + for _, name := range []string{"rootdot.example.de.", "xn--mnchen-3ya.example.de."} { + Expect(sut.Resolve(ctx, newRequest(name, A))). + Should( + SatisfyAll( + HaveResponseType(ResponseTypeHOSTSFILE), + HaveReturnCode(dns.RcodeSuccess), + BeDNSRecord(name, A, "10.0.0.5"), + ), name) + } + }) + }) + When("Hosts file has too many errors", func() { BeforeEach(func() { tmpFile = tmpDir.CreateStringFile("hosts-too-many-errors.txt",