feat(lists): parse bracketed IPv6 literals and map IDNs like DNS clients (#2261)

This commit is contained in:
Max
2026-10-07 06:53:34 +00:00
committed by GitHub
parent 784ffc292f
commit 78def9fc46
8 changed files with 427 additions and 53 deletions
+91
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ string spaced-eol.example.com
string sub.domain.with-hyphen.example.com string sub.domain.with-hyphen.example.com
string tab-separated.example.com string tab-separated.example.com
string trailing-hyphen-.example.com string trailing-hyphen-.example.com
string trailing.dot.example.com. string trailing.dot.example.com
string under_score.example.org string under_score.example.org
string xn--bcher-kva.example.com string xn--bcher-kva.example.com
string xn--mnchen-3ya.example.de string xn--mnchen-3ya.example.de
+10
View File
@@ -61,3 +61,13 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com
# --- leading/trailing hyphen labels (allowed by blocky's relaxed validation) --- # --- leading/trailing hyphen labels (allowed by blocky's relaxed validation) ---
-leading-hyphen.example.com -leading-hyphen.example.com
trailing-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
*.
+122 -24
View File
@@ -20,6 +20,16 @@ const (
maxDNSLabelLength = 63 // https://www.rfc-editor.org/rfc/rfc1034#section-3.1 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`. // Hosts parses `r` as a series of `HostsIterator`.
// It supports both the hosts file and host list formats. // 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 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:] { for _, field := range fields[1:] {
host := string(field) host, err := toASCII(string(field))
if err != nil {
return err
}
if err := validateDomainName(host); err != nil { if err := validateDomainName(host); err != nil {
return err return err
} }
hosts = append(hosts, host) hosts = append(hosts, trimRootDot(host))
} }
if len(hosts) == 0 { 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) 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 return nil
} }
@@ -241,28 +265,21 @@ func (e WildcardEntry) forEachHost(callback func(string) error) error {
} }
func normalizeHostsListEntry(host string) (string, 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 // remove optional start and end markers for ABP styled lists
host = strings.TrimPrefix(host, "||") host = strings.TrimPrefix(host, "||")
host = strings.TrimSuffix(host, "^") host = strings.TrimSuffix(host, "^")
// IDNA is only needed for entries that contain non-ASCII (Unicode) characters, // URL-style IPv6 literal, as in "||[2001:db8::1]^"
// or an "xn--" ACE prefix (which IDNA validates even when the input is already if ip, ok := unwrapIPv6Literal(host); ok {
// ASCII). For all other (pure-ASCII) entries the ToUnicode/ToASCII dance leaves return ip, nil
// 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) { if !isRegex(host) {
hostUnicode, err := idnaProfile.ToUnicode(host) var err error
if err != nil || hostUnicode == host {
host, err = idnaProfile.ToASCII(host) host, err = toASCII(host)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %s", err, host) return "", err
}
} }
} }
@@ -270,14 +287,95 @@ func normalizeHostsListEntry(host string) (string, error) {
return "", err 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 // needsIDNA reports whether host requires IDNA processing: any non-ASCII byte
// needs it, and so does an "xn--" ACE prefix (matched case-insensitively and // needs it, and so does an "xn--" ACE prefix (matched case-insensitively and
// conservatively anywhere in the string), since IDNA validates punycode labels // conservatively anywhere in the string), since IDNA validates punycode labels
// even for ASCII input. Pure-ASCII input without an ACE prefix is left unchanged // even for ASCII input. Pure-ASCII input without an ACE prefix differs from its
// by IDNA, so it can safely skip it. // IDNA mapping by ASCII case alone, which the caches fold, so it skips IDNA.
func needsIDNA(host string) bool { func needsIDNA(host string) bool {
for i := range len(host) { for i := range len(host) {
c := host[i] c := host[i]
+74 -27
View File
@@ -1,13 +1,11 @@
package parsers package parsers
import ( import (
"fmt" "bytes"
"net" "net"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"golang.org/x/net/idna"
) )
// Plain table + fuzz tests for the helper functions in hosts.go. They live in a // 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) --- // --- entry normalization (normalizeHostsListEntry) ---
// normalizeReference is a verbatim copy of normalizeHostsListEntry as it was // normalizeReference is normalizeHostsListEntry without the ASCII fast-path:
// before the ASCII fast-path was added. The fast-path must be equivalent to it // every non-regex entry goes through idnaToASCII. The fast-path must be
// for every input (both the returned host and whether an error occurred). // 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) { func normalizeReference(host string) (string, error) {
var err error
var hostUnicode string
idnaProfile := idna.Punycode
host = strings.TrimPrefix(host, "||") host = strings.TrimPrefix(host, "||")
host = strings.TrimSuffix(host, "^") host = strings.TrimSuffix(host, "^")
if ip, ok := unwrapIPv6Literal(host); ok {
return ip, nil
}
if !isRegex(host) { if !isRegex(host) {
hostUnicode, err = idnaProfile.ToUnicode(host) var err error
if err != nil || hostUnicode == host {
host, err = idnaProfile.ToASCII(host) host, err = idnaToASCII(host)
if err != nil { if err != nil {
return "", fmt.Errorf("%w: %s", err, host) return "", err
}
} }
} }
@@ -88,7 +85,7 @@ func normalizeReference(host string) (string, error) {
return "", err return "", err
} }
return host, nil return trimRootDot(host), nil
} }
func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) { func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) {
@@ -96,28 +93,38 @@ func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) {
// pure ASCII (fast-path skips IDNA) // pure ASCII (fast-path skips IDNA)
"example.com", "Example.COM", "under_score.example.org", "example.com", "Example.COM", "under_score.example.org",
"trailing.dot.example.com.", "-leading.example.com", "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 markers
"||abp.example.com^", "||abp.example.org", "||abp.example.com^", "||abp.example.org",
// regex (IDNA always skipped) // regex (IDNA always skipped)
"/^ads\\.example\\.com$/", "/café/", "/^ads\\.example\\.com$/", "/café/", "/[A-Z]+/",
// already-encoded punycode (ASCII) // already-encoded punycode (ASCII)
"xn--mnchen-3ya.example.de", "xn--bcher-kva.example.com", "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) // Unicode (must go through IDNA)
"münchen.example.de", "bücher.example.com", "пример.example.com", "münchen.example.de", "bücher.example.com", "пример.example.com",
"日本語.example.jp", "café.example.fr", "日本語.example.jp", "café.example.fr",
"MÜNCHEN.example.de", "u\u0308ber.example.de", "example.com", "ẞ.example.de",
// mixed punycode + Unicode (the subtle case) // mixed punycode + Unicode (the subtle case)
"xn--mnchen-3ya.café.com", "café.xn--mnchen-3ya.com", "xn--mnchen-3ya.café.com", "café.xn--mnchen-3ya.com",
// Unicode with ABP markers // Unicode with ABP markers
"||münchen.example.de^", "||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 { for _, in := range inputs {
gotHost, gotErr := normalizeHostsListEntry(in) gotHost, gotErr := normalizeHostsListEntry(in)
wantHost, wantErr := normalizeReference(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)", t.Errorf("normalizeHostsListEntry(%q) = (%q, err=%v); reference = (%q, err=%v)",
in, gotHost, gotErr, wantHost, wantErr) in, gotHost, gotErr, wantHost, wantErr)
} }
@@ -125,9 +132,12 @@ func TestNormalizeHostsListEntry_MatchesReference(t *testing.T) {
} }
// FuzzNormalizeHostsListEntry checks the fast-path matches the reference for // 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) { 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) f.Add(s)
} }
@@ -135,7 +145,7 @@ func FuzzNormalizeHostsListEntry(f *testing.F) {
gotHost, gotErr := normalizeHostsListEntry(in) gotHost, gotErr := normalizeHostsListEntry(in)
wantHost, wantErr := normalizeReference(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)", t.Errorf("normalizeHostsListEntry(%q) = (%q, err=%v); reference = (%q, err=%v)",
in, gotHost, gotErr, wantHost, wantErr) in, gotHost, gotErr, wantHost, wantErr)
} }
@@ -185,7 +195,11 @@ func FuzzMightBeIP(f *testing.F) {
// document: // document:
// - a successful HostsFileEntry has a non-nil IP and only valid host names; // - a successful HostsFileEntry has a non-nil IP and only valid host names;
// - HostListEntry normalization is idempotent: re-parsing its own output is a // - 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) { func FuzzHostsUnmarshalText(f *testing.F) {
for _, s := range []string{ for _, s := range []string{
"example.com", "example.com",
@@ -195,9 +209,14 @@ func FuzzHostsUnmarshalText(f *testing.F) {
"::1 ip6-localhost", "::1 ip6-localhost",
"fe80::1%eth0 router.local", "fe80::1%eth0 router.local",
"*.tracker.example.com", "*.tracker.example.com",
"*.münchen.example.de",
`/^ads\.example\.com$/`, `/^ads\.example\.com$/`,
"münchen.example.de", "münchen.example.de",
"MÜNCHEN.example.de",
"xn--mnchen-3ya.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", "", " ", "\t", "1.2.3.4",
} { } {
f.Add(s) f.Add(s)
@@ -205,6 +224,19 @@ func FuzzHostsUnmarshalText(f *testing.F) {
f.Fuzz(func(t *testing.T, in string) { f.Fuzz(func(t *testing.T, in string) {
data := []byte(in) 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. // None of the parsers may panic, regardless of whether they accept the input.
var list HostListEntry var list HostListEntry
@@ -213,6 +245,9 @@ func FuzzHostsUnmarshalText(f *testing.F) {
var file HostsFileEntry var file HostsFileEntry
fileErr := file.UnmarshalText(data) fileErr := file.UnmarshalText(data)
var wildcard WildcardEntry
wildcardErr := wildcard.UnmarshalText(data)
var iter HostsIterator var iter HostsIterator
_ = iter.UnmarshalText(data) _ = iter.UnmarshalText(data)
@@ -222,17 +257,29 @@ func FuzzHostsUnmarshalText(f *testing.F) {
t.Fatalf("HostsFileEntry parsed %q but IP is nil", in) t.Fatalf("HostsFileEntry parsed %q but IP is nil", in)
} }
i := 0
_ = file.forEachHost(func(host string) error { _ = file.forEachHost(func(host string) error {
if err := validateDomainName(host); err != nil { if err := validateDomainName(host); err != nil {
t.Fatalf("HostsFileEntry parsed %q but emitted host %q is invalid: %v", in, host, err) t.Fatalf("HostsFileEntry parsed %q but emitted host %q is invalid: %v", in, host, err)
} }
i++
noVanishedLabel("HostsFileEntry", fields[i], host)
return nil return nil
}) })
} }
if wildcardErr == nil {
noVanishedLabel("WildcardEntry", fields[0], wildcard.String())
}
// Normalizing an already-normalized host-list entry must be a fixpoint. // Normalizing an already-normalized host-list entry must be a fixpoint.
if listErr == nil { if listErr == nil {
abpTrimmed := bytes.TrimSuffix(bytes.TrimPrefix(fields[0], []byte("||")), []byte("^"))
noVanishedLabel("HostListEntry", abpTrimmed, list.String())
var reparsed HostListEntry var reparsed HostListEntry
if err := reparsed.UnmarshalText([]byte(list.String())); err != nil { 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) t.Fatalf("HostListEntry %q normalized to %q which fails to re-parse: %v", in, list.String(), err)
+105 -1
View File
@@ -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() { When("parsing invalid lines", func() {
It("fails", func() { It("fails", func() {
lines := []string{ lines := []string{
"invalidIP localhost", "invalidIP localhost",
"xn---mllerk1va.com", "xn---mllerk1va.com",
"0.0.0.0 xn---mllerk1va.com",
"*.xn---mllerk1va.com",
`/invalid regex ??/`, `/invalid regex ??/`,
"invalid.*.wildcard", "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 { for _, line := range lines {
sut := Hosts(strings.NewReader(line)) sut := Hosts(strings.NewReader(line))
_, err := sut.Next(context.Background()) _, err := sut.Next(context.Background())
Expect(err).Should(HaveOccurred()) Expect(err).Should(HaveOccurred(), line)
Expect(IsNonResumableErr(err)).ShouldNot(BeTrue()) Expect(IsNonResumableErr(err)).ShouldNot(BeTrue())
Expect(sut.Position()).Should(Equal("line 1")) 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() { Describe("HostsIterator.ForEachHost", func() {
@@ -0,0 +1,2 @@
go test fuzz v1
string("0.^")
+22
View File
@@ -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() { When("Hosts file has too many errors", func() {
BeforeEach(func() { BeforeEach(func() {
tmpFile = tmpDir.CreateStringFile("hosts-too-many-errors.txt", tmpFile = tmpDir.CreateStringFile("hosts-too-many-errors.txt",