1 parent
cc7466823b
commit
e8c5b414d9
44 files changed
+7649
-344
No files matched your search
@@ -0,0 +1,150 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// The DoH handler is the one unauthenticated, TCP-terminated entry point in
|
||||
// the process, so its size limits are a security control and not a nicety:
|
||||
// AGENTS.md lists "amplification" under the areas that have been wrong before
|
||||
// and must not lose their cap. Two paths, one cap each:
|
||||
//
|
||||
// - POST: readCapped refuses a body over dns.MaxMsgSize, both from an honest
|
||||
// Content-Length and from a streamed body whose length is not declared.
|
||||
// - GET: the ?dns= parameter must be refused when it cannot possibly be a
|
||||
// DNS message — the encoding of a legal message is at most
|
||||
// base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) characters, and a DNS
|
||||
// message cannot exceed 65535 bytes on the wire (RFC 1035 §4.2.1, the
|
||||
// 16-bit length prefix). Without that bound the only limit is net/http's
|
||||
// MaxHeaderBytes, a megabyte of attacker-chosen base64 decoded per
|
||||
// request, against a cap of 64 KiB on the POST path beside it.
|
||||
|
||||
// oversizedBody returns a body just over the cap. Its content is irrelevant:
|
||||
// nothing past the cap may be read, let alone unpacked.
|
||||
func oversizedBody() []byte { return bytes.Repeat([]byte{0x00}, dns.MaxMsgSize+1) }
|
||||
|
||||
// TestDoHPostBodyOverTheCapIsRefused covers the declared-length branch: an
|
||||
// honest Content-Length over the cap is refused without reading the body.
|
||||
func TestDoHPostBodyOverTheCapIsRefused(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
body := oversizedBody()
|
||||
res, err := h.dohClient(false).Post(h.dohURL(h.base()), contentTypeDNS, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
got, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400: %s", res.StatusCode, got)
|
||||
}
|
||||
if n := len(h.fake.seen()); n != 0 {
|
||||
t.Errorf("an over-cap body reached the engine %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoHPostBodyOverTheCapIsRefusedWithoutAContentLength is the branch that
|
||||
// matters: a client may lie about, or omit, Content-Length. readCapped has to
|
||||
// detect "too large" by reading max+1 bytes itself, not by trusting the
|
||||
// header, or the cap is a suggestion.
|
||||
func TestDoHPostBodyOverTheCapIsRefusedWithoutAContentLength(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
// An unknown-length body makes net/http send it chunked, so the server
|
||||
// sees ContentLength == -1 and has to use its own reader.
|
||||
body := io.NopCloser(bytes.NewReader(oversizedBody()))
|
||||
req, err := http.NewRequest(http.MethodPost, h.dohURL(h.base()), body)
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
req.ContentLength = -1
|
||||
req.Header.Set("Content-Type", contentTypeDNS)
|
||||
res, err := h.dohClient(false).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
got, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400: %s", res.StatusCode, got)
|
||||
}
|
||||
if n := len(h.fake.seen()); n != 0 {
|
||||
t.Errorf("an over-cap chunked body reached the engine %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoHGETQueryStringOverTheCapIsRefused: a ?dns= parameter longer than any
|
||||
// legal encoding is refused before it is decoded.
|
||||
func TestDoHGETQueryStringOverTheCapIsRefused(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
cl := h.dohClient(false)
|
||||
// One character past the longest possible encoding of a DNS message.
|
||||
param := base64.RawURLEncoding.EncodeToString(make([]byte, dns.MaxMsgSize+1))
|
||||
for _, size := range []int{len(param), 512 * 1024} {
|
||||
long := param
|
||||
if size > len(param) {
|
||||
long = param + strings.Repeat("A", size-len(param))
|
||||
}
|
||||
res, err := cl.Get("https://" + h.addr("doh") + h.base() + "?dns=" + long)
|
||||
if err != nil {
|
||||
t.Fatalf("GET (%d chars): %v", len(long), err)
|
||||
}
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
// 413 rather than 400: the parameter is a payload that is too big,
|
||||
// and it keeps the two paths symmetric — readCapped on POST answers
|
||||
// "cannot read body" for the same reason. A conforming client can
|
||||
// never reach this status: the cap is the longest possible encoding
|
||||
// of a legal DNS message.
|
||||
if res.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("GET with a %d-character dns parameter = %d, want 413: %s", len(long), res.StatusCode, body)
|
||||
}
|
||||
}
|
||||
if n := len(h.fake.seen()); n != 0 {
|
||||
t.Errorf("an over-cap query string reached the engine %d times", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoHServesALargeButLegalQuery guards the cap from being set too tight. A
|
||||
// resolver may legitimately be handed a message near the 65535-byte wire
|
||||
// limit — here an EDNS0 padding option, the same trick the fake uses for a big
|
||||
// answer — and a cap that rejected it would break real clients, not attackers.
|
||||
func TestDoHServesALargeButLegalQuery(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
q := question("large.test.", dns.TypeA)
|
||||
q.SetEdns0(4096, false)
|
||||
// Size the padding so the packed message lands just under the 65535-byte
|
||||
// wire limit: a header (12) + question (18) + OPT (11) + the padding
|
||||
// option's own 4 bytes of code/length, plus the padding itself.
|
||||
q.IsEdns0().Option = append(q.IsEdns0().Option, &dns.EDNS0_NSID{Nsid: "00"})
|
||||
q.IsEdns0().Option = append(q.IsEdns0().Option, &dns.EDNS0_PADDING{Padding: make([]byte, 65400)})
|
||||
wire, err := q.Pack()
|
||||
if err != nil {
|
||||
t.Fatalf("pack: %v", err)
|
||||
}
|
||||
if len(wire) <= 65000 || len(wire) > dns.MaxMsgSize {
|
||||
t.Fatalf("fixture is %d bytes; it must be near the %d-byte limit and legal", len(wire), dns.MaxMsgSize)
|
||||
}
|
||||
if got := len(base64.RawURLEncoding.EncodeToString(wire)); got > base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize) {
|
||||
t.Fatalf("fixture's encoding is %d characters, over the cap by construction", got)
|
||||
}
|
||||
|
||||
// GET.
|
||||
res, body := h.doHGET(q)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("GET with a %d-byte message = %d, want 200: %s", len(wire), res.StatusCode, body)
|
||||
}
|
||||
// POST.
|
||||
res, body = h.doHPOST(q)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("POST with a %d-byte message = %d, want 200: %s", len(wire), res.StatusCode, body)
|
||||
}
|
||||
if n := len(h.fake.seen()); n != 2 {
|
||||
t.Errorf("engine saw %d requests, want 2", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user