mirror of
https://github.com/0xERR0R/blocky.git
synced 2026-10-11 21:59:54 +00:00
test: add fuzz tests for untrusted-input parsers (#2092)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all clean generate generate-check build test e2e-test e2e-test-coverage lint run fmt docker-build help check-tools
|
.PHONY: all clean generate generate-check build test fuzz e2e-test e2e-test-coverage lint run fmt docker-build help check-tools
|
||||||
.DEFAULT_GOAL:=help
|
.DEFAULT_GOAL:=help
|
||||||
|
|
||||||
VERSION?=$(shell git describe --always --tags)
|
VERSION?=$(shell git describe --always --tags)
|
||||||
@@ -27,6 +27,13 @@ GOLANG_LINT_VERSION=v2.12.2
|
|||||||
|
|
||||||
GINKGO_PROCS?=
|
GINKGO_PROCS?=
|
||||||
|
|
||||||
|
# Fuzzing. Fuzz target seed corpora run as ordinary tests on every `make test`;
|
||||||
|
# this is the opt-in discovery mode that actively generates new inputs. `go test
|
||||||
|
# -fuzz` only fuzzes one target in one package per invocation, so `make fuzz`
|
||||||
|
# loops over every Fuzz* target in FUZZ_PKGS, time-boxing each at FUZZ_TIME.
|
||||||
|
FUZZ_TIME?=30s
|
||||||
|
FUZZ_PKGS?=./config ./util ./lists/parsers
|
||||||
|
|
||||||
# Parallelism for e2e tests. e2e specs are dominated by container startup and
|
# Parallelism for e2e tests. e2e specs are dominated by container startup and
|
||||||
# health-check waits rather than CPU, so oversubscribing beyond the core count
|
# health-check waits rather than CPU, so oversubscribing beyond the core count
|
||||||
# improves wall-clock time. Defaults to -p (one process per core) for local
|
# improves wall-clock time. Defaults to -p (one process per core) for local
|
||||||
@@ -87,6 +94,15 @@ test: check-go ## run tests
|
|||||||
go tool ginkgo --label-filter="!e2e" --coverprofile=coverage.txt --covermode=atomic --cover -r ${GINKGO_PROCS}
|
go tool ginkgo --label-filter="!e2e" --coverprofile=coverage.txt --covermode=atomic --cover -r ${GINKGO_PROCS}
|
||||||
go tool cover -html coverage.txt -o coverage.html
|
go tool cover -html coverage.txt -o coverage.html
|
||||||
|
|
||||||
|
fuzz: check-go ## run each fuzz target for FUZZ_TIME (default 30s); e.g. make fuzz FUZZ_TIME=2m
|
||||||
|
@set -e; \
|
||||||
|
for pkg in $(FUZZ_PKGS); do \
|
||||||
|
for target in $$(go test -list '^Fuzz' $$pkg | grep '^Fuzz'); do \
|
||||||
|
echo "==> fuzzing $$target in $$pkg for $(FUZZ_TIME)"; \
|
||||||
|
go test -run '^$$' -fuzz "^$$target$$" -fuzztime $(FUZZ_TIME) $$pkg; \
|
||||||
|
done; \
|
||||||
|
done
|
||||||
|
|
||||||
e2e-test: check-go check-docker ## run e2e tests
|
e2e-test: check-go check-docker ## run e2e tests
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--build-arg VERSION=blocky-e2e \
|
--build-arg VERSION=blocky-e2e \
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// FuzzParseUpstream exercises ParseUpstream with arbitrary input. The function
|
||||||
|
// parses untrusted upstream specifications, including DNS stamps (sdns://...)
|
||||||
|
// whose payload is base64-decoded and then sliced by an attacker-controlled
|
||||||
|
// length byte in stampWithoutAddrPort. The core invariant is therefore that it
|
||||||
|
// must never panic, no matter how malformed the input is.
|
||||||
|
//
|
||||||
|
// For a successfully parsed *non-stamp* upstream it additionally asserts that
|
||||||
|
// String() is a parse fixpoint: re-parsing u.String() succeeds and yields the
|
||||||
|
// same String(). Stamps are exempt because String() intentionally omits stamp
|
||||||
|
// metadata (certificate fingerprints, bootstrap IPs, common name), so a
|
||||||
|
// round-trip through it is lossy by design.
|
||||||
|
func FuzzParseUpstream(f *testing.F) {
|
||||||
|
seeds := []string{
|
||||||
|
"", ":53", "udp",
|
||||||
|
"8.8.8.8:5353",
|
||||||
|
"[2001:4860:4860::8888]:5353",
|
||||||
|
"tcp-tls:dns.example.com:853",
|
||||||
|
"https://dns.example.com:8443/dns-query",
|
||||||
|
"quic://dns.adguard.com",
|
||||||
|
"dns.example.com#cloudflare-dns.com",
|
||||||
|
// DNS stamps, including a deliberately malformed one (invalid..hostname).
|
||||||
|
"sdns://",
|
||||||
|
"sdns://AAcAAAAAAAAABzguOC44Ljg",
|
||||||
|
"sdns://AgcAAAAAAAAABzEuMC4wLjEAEWludmFsaWQuLmhvc3RuYW1lCi9kbnMtcXVlcnk",
|
||||||
|
"sdns://AwAAAAAAAAAADTE0OS4xMTIuMTEyLjkgIINqrLwxXg3E7t8E8DTYfvzaJI-U3WvkQgHQj8JBJgkJcXVhZDkubmV0",
|
||||||
|
}
|
||||||
|
for _, s := range seeds {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, in string) {
|
||||||
|
u, err := ParseUpstream(in)
|
||||||
|
if err != nil {
|
||||||
|
return // rejecting input is fine; we only require that it not panic
|
||||||
|
}
|
||||||
|
|
||||||
|
// String() is lossy for stamps by design, so the fixpoint check below
|
||||||
|
// does not apply to them.
|
||||||
|
if isDNSStamp(in) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s1 := u.String()
|
||||||
|
|
||||||
|
u2, err := ParseUpstream(s1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseUpstream(%q) succeeded but re-parsing its String() %q failed: %v", in, s1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s2 := u2.String(); s2 != s1 {
|
||||||
|
t.Fatalf("String() is not a fixpoint for input %q: first %q, second %q", in, s1, s2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -173,3 +173,74 @@ func FuzzMightBeIP(f *testing.F) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- entry parsing (HostListEntry / HostsFileEntry / HostsIterator) ---
|
||||||
|
|
||||||
|
// FuzzHostsUnmarshalText feeds arbitrary input to the hosts-list parsers, which
|
||||||
|
// consume untrusted downloaded block/allow lists line by line. Neither the
|
||||||
|
// individual entry types nor the HostsIterator that dispatches between them may
|
||||||
|
// panic on any input.
|
||||||
|
//
|
||||||
|
// Beyond the no-panic guarantee it asserts the post-conditions the parsers
|
||||||
|
// 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.
|
||||||
|
func FuzzHostsUnmarshalText(f *testing.F) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"example.com",
|
||||||
|
"||ads.example.com^",
|
||||||
|
"0.0.0.0 ads.example.com",
|
||||||
|
"127.0.0.1 localhost local.host",
|
||||||
|
"::1 ip6-localhost",
|
||||||
|
"fe80::1%eth0 router.local",
|
||||||
|
"*.tracker.example.com",
|
||||||
|
`/^ads\.example\.com$/`,
|
||||||
|
"münchen.example.de",
|
||||||
|
"xn--mnchen-3ya.example.de",
|
||||||
|
"", " ", "\t", "1.2.3.4",
|
||||||
|
} {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, in string) {
|
||||||
|
data := []byte(in)
|
||||||
|
|
||||||
|
// None of the parsers may panic, regardless of whether they accept the input.
|
||||||
|
var list HostListEntry
|
||||||
|
listErr := list.UnmarshalText(data)
|
||||||
|
|
||||||
|
var file HostsFileEntry
|
||||||
|
fileErr := file.UnmarshalText(data)
|
||||||
|
|
||||||
|
var iter HostsIterator
|
||||||
|
_ = iter.UnmarshalText(data)
|
||||||
|
|
||||||
|
// A successful hosts-file entry must carry a usable IP and valid names.
|
||||||
|
if fileErr == nil {
|
||||||
|
if file.IP == nil {
|
||||||
|
t.Fatalf("HostsFileEntry parsed %q but IP is nil", in)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizing an already-normalized host-list entry must be a fixpoint.
|
||||||
|
if listErr == nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reparsed != list {
|
||||||
|
t.Fatalf("HostListEntry normalization is not idempotent: %q -> %q -> %q", in, list.String(), reparsed.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzParseIPFromArpaAddr exercises ParseIPFromArpaAddr, which parses reverse-DNS
|
||||||
|
// (PTR) names taken directly off the network, with arbitrary input. However
|
||||||
|
// malformed the name is, parsing must never panic.
|
||||||
|
func FuzzParseIPFromArpaAddr(f *testing.F) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"", "in-addr.arpa.", "ip6.arpa.",
|
||||||
|
"8.8.8.8.in-addr.arpa.",
|
||||||
|
"1.0.0.127.in-addr.arpa.",
|
||||||
|
"x.8.8.8.in-addr.arpa.",
|
||||||
|
"256.8.8.8.in-addr.arpa.",
|
||||||
|
"4.3.2.1.0.d.d.4.1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.ip6.arpa.",
|
||||||
|
} {
|
||||||
|
f.Add(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, in string) {
|
||||||
|
_, _ = ParseIPFromArpaAddr(in) // must not panic
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzParseIPFromArpaAddrRoundtrip cross-checks ParseIPFromArpaAddr against
|
||||||
|
// dns.ReverseAddr (from miekg/dns, already a dependency) as an oracle: for any
|
||||||
|
// valid IPv4 or IPv6 address, the arpa name produced by ReverseAddr must parse
|
||||||
|
// back to the same address.
|
||||||
|
func FuzzParseIPFromArpaAddrRoundtrip(f *testing.F) {
|
||||||
|
f.Add([]byte{8, 8, 8, 8})
|
||||||
|
f.Add([]byte{127, 0, 0, 1})
|
||||||
|
f.Add([]byte(net.ParseIP("2001:db8::1").To16()))
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, raw []byte) {
|
||||||
|
var ip net.IP
|
||||||
|
|
||||||
|
switch len(raw) {
|
||||||
|
case net.IPv4len:
|
||||||
|
ip = net.IPv4(raw[0], raw[1], raw[2], raw[3])
|
||||||
|
case net.IPv6len:
|
||||||
|
ip = net.IP(raw).To16()
|
||||||
|
default:
|
||||||
|
return // only 4- and 16-byte inputs map to a concrete IP
|
||||||
|
}
|
||||||
|
|
||||||
|
arpa, err := dns.ReverseAddr(ip.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dns.ReverseAddr(%s) failed: %v", ip, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseIPFromArpaAddr(arpa)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseIPFromArpaAddr(%q) failed for ip %s: %v", arpa, ip, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !got.Equal(ip) {
|
||||||
|
t.Fatalf("round-trip mismatch: ip %s -> arpa %q -> %s", ip, arpa, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user