mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-11 21:59:54 +00:00
feat(lists): parse bracketed IPv6 literals and map IDNs like DNS clients (#2261)
This commit is contained in:
Vendored
+91
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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.^")
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user