dns/scan_test.go
Tom Thorogood 274da7d3ef
Add new ZoneParser API (#794)
* Improve ParseZone tests

* Add new ZoneParser API

* Use the ZoneParser API directly in ReadRR

* Merge parseZoneHelper into ParseZone

* Make generate string building slightly more efficient

* Add SetDefaultTTL method to ZoneParser

This makes it possible for external consumers to implement ReadRR.

* Make $INCLUDE directive opt-in

The $INCLUDE directive opens a user controlled file and parses it as
a DNS zone file. The error messages may reveal portions of sensitive
files, such as:
	/etc/passwd: dns: not a TTL: "root0:0:root:/root:/bin/bash" at line: 1:31
	/etc/shadow: dns: not a TTL: "root:$6$<redacted>::0:99999:7:::" at line: 1:125

Both ParseZone and ReadRR are currently opt-in for backward
compatibility.

* Disable $INCLUDE support in ReadRR

ReadRR and NewRR are often passed untrusted input. At the same time,
$INCLUDE isn't really useful for ReadRR as it only ever returns the
first record.

This is a breaking change, but it currently represents a slight
security risk.

* Document the need to drain the ParseZone chan

* Cleanup the documentation of NewRR, ReadRR and ParseZone

* Document the ZoneParser API

* Deprecated the ParseZone function

* Add whitespace to ZoneParser.Next

* Remove prevName field from ZoneParser

This doesn't track anything meaningful as both zp.prevName and h.Name
are only ever set at the same point and to the same value.

* Use uint8 for ZoneParser.include field

It has a maximum value of 7 which easily fits within uint8.

This reduces the size of ZoneParser from 160 bytes to 152 bytes.

* Add setParseError helper to ZoneParser

* Surface $INCLUDE os.Open error in error message

* Rename ZoneParser.include field to includeDepth

* Make maximum $INCLUDE depth a const

* Add ParseZone and ZoneParser benchmarks

* Parse $GENERATE directive with a single ZoneParser

This should be more efficient than calling NewRR for each generated
record.

* Run go fmt on generate_test.go

* Add a benchmark for $GENERATE directives

* Use a custom reader for generate

This avoids the overhead and memory usage of building the zone string.

name         old time/op    new time/op    delta
Generate-12     165µs ± 4%     157µs ± 2%   -5.06%  (p=0.000 n=25+25)

name         old alloc/op   new alloc/op   delta
Generate-12    42.1kB ± 0%    31.8kB ± 0%  -24.42%  (p=0.000 n=20+23)

name         old allocs/op  new allocs/op  delta
Generate-12     1.56k ± 0%     1.55k ± 0%   -0.38%  (p=0.000 n=25+25)

* Return correct ParseError from generateReader

The last commit made these regular errors while they had been
ParseErrors before.

* Return error message as string from modToPrintf

This is slightly simpler and they don't need to be errors.

* Skip setting includeDepth in generate

This sub parser isn't allowed to use $INCLUDE directives anyway.

Note: If generate is ever changed to allow $INCLUDE directives, then
      this line must be added back. Without doing that, it would be
      be possible to exceed maxIncludeDepth.

* Make generateReader errors sticky

ReadByte should not be called after an error has been returned, but
this is cheap insurance.

* Move file and lex fields to end of generateReader

These are only used for creating a ParseError and so are unlikely to be
accessed.

* Don't return offset with error in modToPrintf

Along for the ride, are some whitespace and style changes.

* Add whitespace to generate and simplify step

* Use a for loop instead of goto in generate

* Support $INCLUDE directives inside $GENERATE directives

This was previously supported and may be useful. This is now more
rigorous as the maximum include depth is respected and relative
$INCLUDE directives are now supported from within $GENERATE.

* Don't return any lexer tokens after read error

Without this, read errors are likely to be lost and become parse errors
of the remaining str. The $GENERATE code relies on surfacing errors from
the reader.

* Support $INCLUDE in NewRR and ReadRR

Removing $INCLUDE support from these is a breaking change and should
not be included in this pull request.

* Add test to ensure $GENERATE respects $INCLUDE support

* Unify TestZoneParserIncludeDisallowed with other tests

* Remove stray whitespace from TestGenerateSurfacesErrors

* Move ZoneParser SetX methods above Err method

* $GENERATE should not accept step of 0

If step is allowed to be 0, then generateReader (and the code it
replaced) will get stuck in an infinite loop.

This is a potential DOS vulnerability.

* Fix ReadRR comment for file argument

I missed this previosuly. The file argument is also used to
resolve relative $INCLUDE directives.

* Prevent test panics on nil error

* Rework ZoneParser.subNext

This is slightly cleaner and will close the underlying *os.File even
if an error occurs.

* Make ZoneParser.generate call subNext

This also moves the calls to setParseError into generate.

* Report errors when parsing rest of $GENERATE directive

* Report proper error location in $GENERATE directive

This makes error messages much clearer.

* Simplify modToPrintf func

Note: When width is 0, the leading 0 of the fmt string is now excluded.
      This should not alter the formatting of numbers in anyway.

* Add comment explaining sub field

* Remove outdated error comment from generate
2018-10-20 11:47:56 +10:30

219 lines
5.6 KiB
Go

package dns
import (
"io"
"io/ioutil"
"net"
"os"
"strings"
"testing"
)
func TestParseZoneGenerate(t *testing.T) {
zone := "$ORIGIN example.org.\n$GENERATE 10-12 foo${2,3,d} IN A 127.0.0.$"
wantRRs := []RR{
&A{Hdr: RR_Header{Name: "foo012.example.org."}, A: net.ParseIP("127.0.0.10")},
&A{Hdr: RR_Header{Name: "foo013.example.org."}, A: net.ParseIP("127.0.0.11")},
&A{Hdr: RR_Header{Name: "foo014.example.org."}, A: net.ParseIP("127.0.0.12")},
}
wantIdx := 0
tok := ParseZone(strings.NewReader(zone), "", "")
for x := range tok {
if wantIdx >= len(wantRRs) {
t.Fatalf("expected %d RRs, but got more", len(wantRRs))
}
if x.Error != nil {
t.Fatalf("expected no error, but got %s", x.Error)
}
if got, want := x.RR.Header().Name, wantRRs[wantIdx].Header().Name; got != want {
t.Fatalf("expected name %s, but got %s", want, got)
}
a, ok := x.RR.(*A)
if !ok {
t.Fatalf("expected *A RR, but got %T", x.RR)
}
if got, want := a.A, wantRRs[wantIdx].(*A).A; !got.Equal(want) {
t.Fatalf("expected A with IP %v, but got %v", got, want)
}
wantIdx++
}
if wantIdx != len(wantRRs) {
t.Errorf("too few records, expected %d, got %d", len(wantRRs), wantIdx)
}
}
func TestParseZoneInclude(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "dns")
if err != nil {
t.Fatalf("could not create tmpfile for test: %s", err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString("foo\tIN\tA\t127.0.0.1"); err != nil {
t.Fatalf("unable to write content to tmpfile %q: %s", tmpfile.Name(), err)
}
if err := tmpfile.Close(); err != nil {
t.Fatalf("could not close tmpfile %q: %s", tmpfile.Name(), err)
}
zone := "$ORIGIN example.org.\n$INCLUDE " + tmpfile.Name() + "\nbar\tIN\tA\t127.0.0.2"
var got int
tok := ParseZone(strings.NewReader(zone), "", "")
for x := range tok {
if x.Error != nil {
t.Fatalf("expected no error, but got %s", x.Error)
}
switch x.RR.Header().Name {
case "foo.example.org.", "bar.example.org.":
default:
t.Fatalf("expected foo.example.org. or bar.example.org., but got %s", x.RR.Header().Name)
}
got++
}
if expected := 2; got != expected {
t.Errorf("failed to parse zone after include, expected %d records, got %d", expected, got)
}
os.Remove(tmpfile.Name())
tok = ParseZone(strings.NewReader(zone), "", "")
for x := range tok {
if x.Error == nil {
t.Fatalf("expected first token to contain an error but it didn't")
}
if !strings.Contains(x.Error.Error(), "failed to open") ||
!strings.Contains(x.Error.Error(), tmpfile.Name()) ||
!strings.Contains(x.Error.Error(), "no such file or directory") {
t.Fatalf(`expected error to contain: "failed to open", %q and "no such file or directory" but got: %s`,
tmpfile.Name(), x.Error)
}
}
}
func TestZoneParserIncludeDisallowed(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "dns")
if err != nil {
t.Fatalf("could not create tmpfile for test: %s", err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.WriteString("foo\tIN\tA\t127.0.0.1"); err != nil {
t.Fatalf("unable to write content to tmpfile %q: %s", tmpfile.Name(), err)
}
if err := tmpfile.Close(); err != nil {
t.Fatalf("could not close tmpfile %q: %s", tmpfile.Name(), err)
}
zp := NewZoneParser(strings.NewReader("$INCLUDE "+tmpfile.Name()), "example.org.", "")
for _, ok := zp.Next(); ok; _, ok = zp.Next() {
}
const expect = "$INCLUDE directive not allowed"
if err := zp.Err(); err == nil || !strings.Contains(err.Error(), expect) {
t.Errorf("expected error to contain %q, got %v", expect, err)
}
}
func TestParseTA(t *testing.T) {
rr, err := NewRR(` Ta 0 0 0`)
if err != nil {
t.Fatalf("expected no error, but got %s", err)
}
if rr == nil {
t.Fatal(`expected a normal RR, but got nil`)
}
}
var errTestReadError = &Error{"test error"}
type errReader struct{}
func (errReader) Read(p []byte) (int, error) { return 0, errTestReadError }
func TestParseZoneReadError(t *testing.T) {
rr, err := ReadRR(errReader{}, "")
if err == nil || !strings.Contains(err.Error(), errTestReadError.Error()) {
t.Errorf("expected error to contain %q, but got %v", errTestReadError, err)
}
if rr != nil {
t.Errorf("expected a nil RR, but got %v", rr)
}
}
func BenchmarkNewRR(b *testing.B) {
const name1 = "12345678901234567890123456789012345.12345678.123."
const s = name1 + " 3600 IN MX 10 " + name1
for n := 0; n < b.N; n++ {
_, err := NewRR(s)
if err != nil {
b.Fatal(err)
}
}
}
func BenchmarkReadRR(b *testing.B) {
const name1 = "12345678901234567890123456789012345.12345678.123."
const s = name1 + " 3600 IN MX 10 " + name1 + "\n"
for n := 0; n < b.N; n++ {
r := struct{ io.Reader }{strings.NewReader(s)}
// r is now only an io.Reader and won't benefit from the
// io.ByteReader special-case in zlexer.Next.
_, err := ReadRR(r, "")
if err != nil {
b.Fatal(err)
}
}
}
const benchZone = `
foo. IN A 10.0.0.1 ; this is comment 1
foo. IN A (
10.0.0.2 ; this is comment 2
)
; this is comment 3
foo. IN A 10.0.0.3
foo. IN A ( 10.0.0.4 ); this is comment 4
foo. IN A 10.0.0.5
; this is comment 5
foo. IN A 10.0.0.6
foo. IN DNSKEY 256 3 5 AwEAAb+8l ; this is comment 6
foo. IN NSEC miek.nl. TXT RRSIG NSEC; this is comment 7
foo. IN TXT "THIS IS TEXT MAN"; this is comment 8
`
func BenchmarkParseZone(b *testing.B) {
for n := 0; n < b.N; n++ {
for tok := range ParseZone(strings.NewReader(benchZone), "example.org.", "") {
if tok.Error != nil {
b.Fatal(tok.Error)
}
}
}
}
func BenchmarkZoneParser(b *testing.B) {
for n := 0; n < b.N; n++ {
zp := NewZoneParser(strings.NewReader(benchZone), "example.org.", "")
for _, ok := zp.Next(); ok; _, ok = zp.Next() {
}
if err := zp.Err(); err != nil {
b.Fatal(err)
}
}
}