Files
fancy-dns/internal/server/doh_limits_test.go
T
developer e8c5b414d9
build / build-push (push) Failing after 23s
half broken
2026-09-27 17:17:53 +00:00

151 lines
6.0 KiB
Go

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)
}
}