3 Commits

Author SHA1 Message Date
Patricio Whittingslow 7929b45623 dns: simplify @hnw function and additional fixes 2026-08-28 01:30:22 -03:00
Yoshio HANAWA 8c49548440 refactor(dns): eagerly decode CNAME target into r.data, drop target field
Address review feedback on #189:

- Remove Resource.target: Resource.Decode expands CNAME RDATA in place
  into r.data (uncompressed wire format) while the full message is still
  available and updates header.Length, storing the name bytes exactly once.
- Add Resource.CNAMEView returning a length-bounded view of the expanded
  target; Message.WriteAnswers uses it.
- Rename matchesHost to ResourceHeader.pertainsTo.
- Make ResolveConfig.MaxCNAMEs explicit: drop the silent default in
  Client.StartResolve and let callers opt in (x/xnet).
- Reduce test suite to regression coverage only.

refs #189
2026-08-27 09:46:00 +09:00
Yoshio HANAWA a33e9c5fab fix(dns): resolve CNAME chains and prevent invalid IP parsing
- Add automated in-band CNAME chain resolution to extract final A/AAAA IP addresses.
- Replace `MaxResponseAnswers` with `MaxIPs` and `MaxCNAMEs` to explicitly bound resource decoding and prevent memory exhaustion.
- Bound CNAME chain traversal to prevent infinite loops from cyclic records.
2026-08-23 18:27:35 +09:00
9 changed files with 369 additions and 56 deletions
+4 -2
View File
@@ -25,7 +25,9 @@ type ResolveConfig struct {
Additional []Resource
EnableRecursion bool
// MaxResponseAnswers limits how many answer records are decoded from the
// DNS response. If zero it defaults to the number of Questions.
// DNS response. If zero it defaults to the number of Questions. Answers
// are decoded in wire order regardless of type, so a response resolved
// through CNAMEs needs room for the CNAME records as well as the addresses.
MaxResponseAnswers uint16
}
@@ -37,7 +39,7 @@ func (sudp *Client) ConnectionID() *uint64 { return &sudp.connID }
func (c *Client) StartResolve(localPort, txid uint16, cfg ResolveConfig) error {
nd := len(cfg.Questions)
if nd > math.MaxUint16 {
if nd > math.MaxUint16 || nd == 0 {
return lneto.ErrInvalidConfig
}
maxAns := cfg.MaxResponseAnswers
+2
View File
@@ -197,6 +197,8 @@ const (
TypeALL Type = 255 // ALL
)
func (tp Type) IsIPAddr() bool { return tp == TypeA || tp == TypeAAAA }
// A Class is a type of network.
type Class uint16
+72 -16
View File
@@ -99,6 +99,12 @@ func NamesEqual(a, b Name) bool {
return internal.BytesEqual(a.data, b.data)
}
// NamesEqualFold reports whether two DNS names are equal under ASCII case
// folding, which is how DNS labels compare per RFC 1035 section 2.3.3.
func NamesEqualFold(a, b Name) bool {
return internal.BytesEqualFoldASCII(a.data, b.data)
}
type ZFlags uint16
func NewResource(name Name, typ Type, class Class, ttl uint32, data []byte) Resource {
@@ -299,27 +305,57 @@ func (m *Message) AppendTo(buf []byte, txid uint16, flags HeaderFlags) (_ []byte
return buf, nil
}
// WriteAnswers writes the addresses answering host into dst, following the
// CNAME chain rooted at host. It returns the number of addresses written.
func (m *Message) WriteAnswers(dst []netip.Addr, host string) (n uint16, err error) {
for i := range m.Answers {
if int(n) >= len(dst) {
return n, lneto.ErrExhausted
// Each round resolves one CNAME, which consumes an answer. Bounding the
// walk by the answer count is thus enough to reach the addresses, and
// terminates on cyclic chains.
var alias Name // Canonical name reached so far; zero means host itself.
for range m.Answers {
var next Name
for i := range m.Answers {
ans := &m.Answers[i]
if !ans.header.ownedBy(alias, host) {
continue
}
switch {
case ans.header.Type.IsIPAddr():
if int(n) >= len(dst) {
return n, lneto.ErrExhausted
}
addr, ok := netip.AddrFromSlice(ans.RawData())
if !ok {
err = lneto.ErrInvalidAddr
continue
}
dst[n] = addr
n++
case ans.header.Type == TypeCNAME:
if cname := ans.CNAMEView(); cname.Len() != 0 {
next = cname
}
}
}
ans := &m.Answers[i]
hdr := ans.Header()
if !hdr.Name.EqualString(host) {
continue
}
var ok bool
dst[n], ok = netip.AddrFromSlice(ans.RawData())
if !ok {
err = lneto.ErrInvalidAddr
} else {
n++
if n > 0 || next.Len() == 0 {
break
}
alias = next
}
return n, err
}
// ownedBy reports whether the record's owner name is the name being resolved:
// the alias reached by following CNAMEs, or host at the root of the chain.
func (h *ResourceHeader) ownedBy(alias Name, host string) bool {
if alias.Len() == 0 {
return h.Name.EqualString(host)
}
// Fold: the server chooses the case of both the CNAME target and the owner
// name of the records it aliases, and may randomize it (DNS 0x20).
return NamesEqualFold(h.Name, alias)
}
func (m *Message) Len() uint16 {
return SizeHeader + m.lenResources()
}
@@ -411,6 +447,15 @@ func (r *Resource) RawData() []byte {
return r.data[:length]
}
// CNAMEView returns the canonical name held by a CNAME record, aliasing the
// Resource's buffer. It returns a zero Name for any other record type.
func (r *Resource) CNAMEView() Name {
if r.header.Type != TypeCNAME {
return Name{}
}
return Name{data: r.RawData()}
}
func (q *Question) Reset() {
q.Name.Reset()
*q = Question{Name: q.Name} // Reuse Name's buffer.
@@ -460,8 +505,19 @@ func (r *Resource) Decode(b []byte, off uint16) (uint16, error) {
if r.header.Length > uint16(len(b[off:])) {
return off, errResourceLen
}
r.data = append(r.data[:0], b[off:off+r.header.Length]...)
return off + r.header.Length, nil
end := off + r.header.Length
if r.header.Type == TypeCNAME {
// CNAME data is a name which may use message compression. Expand it now
// since r.data is detached from b, leaving pointers unresolvable later.
cname := Name{data: r.data[:0]}
if _, derr := cname.Decode(b, off); derr == nil {
r.data = cname.data
r.header.Length = uint16(len(r.data))
return end, nil
}
}
r.data = append(r.data[:0], b[off:end]...)
return end, nil
}
func (r *Resource) appendTo(buf []byte) (_ []byte, err error) {
+148 -2
View File
@@ -239,6 +239,152 @@ func TestDecodeMessage(t *testing.T) {
}
}
// Regression test for CNAME-following: a response for www.yahoo.co.jp
// contains a CNAME record to edge12.g.yimg.jp (with compressed labels in its
// RDATA) followed by the A record for the canonical name. The CNAME RDATA
// must not be interpreted as an IP address and the A record must be returned.
func TestClient_CNAMEResponse(t *testing.T) {
const hostname = "www.yahoo.co.jp"
const txid = uint16(0x1234)
const clientPort = uint16(54321)
response := []byte{
// Header: txid 0x1234, QR|RD|RA, QD=1 AN=2 NS=0 AR=0.
0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
// Question: www.yahoo.co.jp A IN.
0x03, 'w', 'w', 'w', 0x05, 'y', 'a', 'h', 'o', 'o', 0x02, 'c', 'o', 0x02, 'j', 'p', 0x00,
0x00, 0x01, 0x00, 0x01,
// Answer 1: (ptr to question) CNAME IN ttl=842 rdlen=16
// rdata: edge12.g.yimg.jp with "jp" as compression pointer to offset 0x19.
0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x03, 0x4a, 0x00, 0x10,
0x06, 'e', 'd', 'g', 'e', '1', '2', 0x01, 'g', 0x04, 'y', 'i', 'm', 'g', 0xc0, 0x19,
// Answer 2: (ptr into CNAME rdata) A IN ttl=36 rdlen=4 182.22.23.124.
0xc0, 0x2d, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x24, 0x00, 0x04, 0xb6, 0x16, 0x17, 0x7c,
}
name := MustNewName(hostname)
var client Client
err := client.StartResolve(clientPort, txid, ResolveConfig{
Questions: []Question{{
Name: name,
Type: TypeA,
Class: ClassINET,
}},
EnableRecursion: true,
MaxResponseAnswers: 6,
})
if err != nil {
t.Fatal("failed to start DNS resolve:", err)
}
var queryBuf [512]byte
_, err = client.Encapsulate(queryBuf[:], 0, 0)
if err != nil {
t.Fatal("failed to encapsulate DNS query:", err)
}
if err := client.Demux(response, 0); err != nil {
t.Fatal("failed to demux DNS response:", err)
}
var addrs [4]netip.Addr
n, err := client.ResponseAnswerLookup(addrs[:], hostname)
if err != nil {
t.Fatal("failed to look up DNS response answers:", err)
}
if n != 1 {
t.Fatalf("expected 1 answer, got %d: %v", n, addrs[:n])
}
if addrs[0] != (netip.AddrFrom4([4]byte{182, 22, 23, 124})) {
t.Fatalf("expected 182.22.23.124, got %v", addrs[0])
}
}
// Table-driven tests for Message.WriteAnswers covering answer reordering
// and cyclic CNAME aliases.
func TestMessage_WriteAnswers(t *testing.T) {
tests := []struct {
name string
host string
response []byte
want []netip.Addr
}{
{
name: "A record before its CNAME",
host: "www.yahoo.co.jp",
// Answer 1 is the A record for edge12.g.yimg.jp, spelled out with
// a trailing compression pointer to "jp" in the question. Answer 2
// is the CNAME from www.yahoo.co.jp whose RDATA is a single
// backward compression pointer to answer 1's owner name.
response: []byte{
// Header: txid 0x1234, QR|RD|RA, QD=1 AN=2 NS=0 AR=0.
0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
// Question: www.yahoo.co.jp A IN.
0x03, 'w', 'w', 'w', 0x05, 'y', 'a', 'h', 'o', 'o', 0x02, 'c', 'o', 0x02, 'j', 'p', 0x00,
0x00, 0x01, 0x00, 0x01,
// Answer 1: edge12.g.yimg.jp A IN ttl=36 rdlen=4 182.22.23.124.
0x06, 'e', 'd', 'g', 'e', '1', '2', 0x01, 'g', 0x04, 'y', 'i', 'm', 'g', 0xc0, 0x19,
0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x24, 0x00, 0x04, 0xb6, 0x16, 0x17, 0x7c,
// Answer 2: (ptr to question) CNAME IN ttl=842 rdlen=2, target
// is a pointer to answer 1's owner name at offset 0x21.
0xc0, 0x0c, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x03, 0x4a, 0x00, 0x02, 0xc0, 0x21,
},
want: []netip.Addr{netip.AddrFrom4([4]byte{182, 22, 23, 124})},
},
{
name: "CNAME cycle terminates",
host: "a.com",
response: []byte{
// Header: txid 0xabcd, QR|RD|RA, QD=1 AN=2 NS=0 AR=0.
0xab, 0xcd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
// Question: a.com A IN.
0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01,
// Answer 1: a.com CNAME b.com.
0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x07, 0x01, 'b', 0x03, 'c', 'o', 'm', 0x00,
// Answer 2: b.com CNAME a.com.
0x01, 'b', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x07, 0x01, 'a', 0x03, 'c', 'o', 'm', 0x00,
},
want: nil,
},
{
name: "CNAME target case differs from owner name",
host: "a.com",
// A server picks the case of both the CNAME target and the owner
// name of the record it aliases, and may randomize it (DNS 0x20),
// so the two must compare under ASCII case folding.
response: []byte{
// Header: txid 0xabcd, QR|RD|RA, QD=1 AN=2 NS=0 AR=0.
0xab, 0xcd, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
// Question: a.com A IN.
0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01,
// Answer 1: a.com CNAME B.CoM.
0x01, 'a', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x07, 0x01, 'B', 0x03, 'C', 'o', 'M', 0x00,
// Answer 2: b.com A IN ttl=10 rdlen=4 1.2.3.4.
0x01, 'b', 0x03, 'c', 'o', 'm', 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x04, 0x01, 0x02, 0x03, 0x04,
},
want: []netip.Addr{netip.AddrFrom4([4]byte{1, 2, 3, 4})},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var msg Message
msg.LimitResourceDecoding(1, 4, 0, 0)
_, incomplete, err := msg.Decode(tt.response)
if incomplete || err != nil {
t.Fatal("decode:", incomplete, err)
}
var addrs [4]netip.Addr
n, err := msg.WriteAnswers(addrs[:], tt.host)
if err != nil {
t.Fatal("write answers:", err)
}
if n != uint16(len(tt.want)) {
t.Fatalf("expected %d addresses, got %d: %v", len(tt.want), n, addrs[:n])
}
for i, want := range tt.want {
if addrs[i] != want {
t.Errorf("address %d: expected %v, got %v", i, want, addrs[i])
}
}
})
}
}
func TestClient_ReceivesDNSResponse(t *testing.T) {
const hostname = "example.com"
const txid = uint16(12345)
@@ -254,7 +400,7 @@ func TestClient_ReceivesDNSResponse(t *testing.T) {
tests := []struct {
name string
responseIPs [][4]byte
wantAnswers int
wantAnswers int // Addresses returned by ResponseAnswerLookup and copied by ResponseCopyTo.
}{
{name: "single_answer", responseIPs: allIPs[:1], wantAnswers: 1},
{name: "multiple_answers", responseIPs: allIPs[:4], wantAnswers: 4},
@@ -312,7 +458,7 @@ func TestClient_ReceivesDNSResponse(t *testing.T) {
if err != nil {
t.Fatal("failed to look up DNS response answers:", err)
}
if answers != uint16(tt.wantAnswers) {
if int(answers) != tt.wantAnswers {
t.Fatalf("expected %d answers, got %d", tt.wantAnswers, answers)
}
for i := 0; i < tt.wantAnswers; i++ {
+1 -19
View File
@@ -300,25 +300,7 @@ func (kvb *kvBuffer) getFoldIdx(key string) int {
// EqualFoldASCII reports whether a and b are equal under ASCII case folding.
// Unlike strings.EqualFold it does not fold non-ASCII runes, so no multi-byte
// rune such as U+212A KELVIN SIGN can alias a header key.
func EqualFoldASCII(a, b string) bool {
if len(a) != len(b) {
return false
}
const asciiCapDiff = 'a' - 'A'
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += asciiCapDiff
}
if cb >= 'A' && cb <= 'Z' {
cb += asciiCapDiff
}
if ca != cb {
return false
}
}
return true
}
func EqualFoldASCII(a, b string) bool { return internal.EqualFoldASCII(a, b) }
// reserve ensures need free bytes are available in the buffer, growing it when
// permitted. It accounts for the byte-0 reservation on an empty buffer (see
+2 -15
View File
@@ -253,20 +253,7 @@ func trimOWS(b []byte) []byte {
return b
}
// equalFold compares b to the ASCII lowercase key, case insensitively.
// equalFold compares b to key, case insensitively.
func equalFold(b []byte, key string) bool {
if len(b) != len(key) {
return false
}
const asciiCapDiff = 'a' - 'A'
for i := range b {
c := b[i]
if c >= 'A' && c <= 'Z' {
c += asciiCapDiff
}
if c != key[i] {
return false
}
}
return true
return EqualFoldASCII(b2s(b), key)
}
+34
View File
@@ -14,6 +14,40 @@ func BytesEqual(a, b []byte) bool {
return unsafe.String(&a[0], len(a)) == unsafe.String(&b[0], len(b))
}
// EqualFoldASCII reports whether a and b are equal under ASCII case folding.
// Unlike [strings.EqualFold] it does not fold non-ASCII runes, so no multi-byte
// rune such as U+212A KELVIN SIGN can alias an ASCII key.
func EqualFoldASCII(a, b string) bool {
if len(a) != len(b) {
return false
}
const asciiCapDiff = 'a' - 'A'
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += asciiCapDiff
}
if cb >= 'A' && cb <= 'Z' {
cb += asciiCapDiff
}
if ca != cb {
return false
}
}
return true
}
// BytesEqualFoldASCII is the []byte form of [EqualFoldASCII]. Like [BytesEqual]
// it is heapless in tinygo, unlike [bytes.EqualFold] which also folds non-ASCII.
func BytesEqualFoldASCII(a, b []byte) bool {
if len(a) != len(b) {
return false
} else if len(a) == 0 {
return true
}
return EqualFoldASCII(unsafe.String(&a[0], len(a)), unsafe.String(&b[0], len(b)))
}
// IsZeroed returns true if all arguments are set to their zero value.
func IsZeroed[T comparable](a ...T) bool {
var z T
+4 -2
View File
@@ -629,8 +629,10 @@ func (s *StackAsync) StartLookupIPType(host string, qtype dns.Type) error {
Additional: []dns.Resource{
s.ednsopt,
},
EnableRecursion: true,
MaxResponseAnswers: uint16(len(s.addrbufnip)),
EnableRecursion: true,
// Leave headroom above the address buffer for CNAME records, which
// occupy answer slots before the addresses they alias.
MaxResponseAnswers: uint16(len(s.addrbufnip)) + 8,
})
if err != nil {
return err
+102
View File
@@ -156,6 +156,14 @@ func buildDNSResponsePacket(t *testing.T, txid uint16, dstPort uint16, hostname
dns.NewResource(name, dns.TypeA, dns.ClassINET, 300, addr.AsSlice()),
},
}
return buildDNSMsgResponsePacket(t, txid, dstPort, msg, srcIP, srcMAC, dstIP, dstMAC, buf)
}
// buildDNSMsgResponsePacket wraps a DNS response message into a complete
// Ethernet+IP+UDP packet with valid checksums.
func buildDNSMsgResponsePacket(t *testing.T, txid uint16, dstPort uint16, msg dns.Message,
srcIP netip.Addr, srcMAC [6]byte, dstIP netip.Addr, dstMAC [6]byte, buf []byte) ([]byte, error) {
t.Helper()
// Response flags: QR=1 (response), RD=1 (recursion desired), RA=1 (recursion available).
responseFlags := dns.HeaderFlags(1<<15 | 1<<8 | 1<<7)
@@ -237,3 +245,97 @@ var errBaseLenDNS = func() error {
}()
var errInvalidEtherType = errors.New("invalid ethernet type")
// TestDNS_CNAMEResponse verifies that a DNS response containing a CNAME record
// followed by an A record for the canonical name resolves to the A record's
// address: the CNAME RDATA must not be misinterpreted as an IP address.
func TestDNS_CNAMEResponse(t *testing.T) {
const seed = 9876
const MTU = ethernet.MaxMTU
client := new(StackAsync)
dnsServerAddr := netip.AddrFrom4([4]byte{8, 8, 8, 8})
clientAddr := netip.AddrFrom4([4]byte{10, 0, 0, 100})
clientMAC := [6]byte{0xde, 0xad, 0xbe, 0xef, 0x00, 0x01}
dnsServerMAC := [6]byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}
err := client.Reset(StackConfig{
Hostname: "DNSClient",
RandSeed: seed,
StaticAddress4: clientAddr.As4(),
DNSServer: dnsServerAddr,
HardwareAddress: clientMAC,
MTU: uint16(MTU),
})
if err != nil {
t.Fatal("client Reset failed:", err)
}
client.SetGatewayHardwareAddr(dnsServerMAC)
const hostname = "www.example.com"
const alias = "cdn.example.net"
wantAddr := netip.MustParseAddr("192.0.2.200")
err = client.StartLookupIP(hostname)
if err != nil {
t.Fatal("StartLookupIP failed:", err)
}
const carrierDataSize = ethernet.MaxFrameLength
var buf [carrierDataSize]byte
// Client sends DNS query for www.example.com.
n, err := client.EgressEthernet(buf[:])
if err != nil || n == 0 {
t.Fatal("expected DNS query packet from client:", err, n)
}
txid, clientPort, err := extractDNSTxIDAndPort(buf[:n])
if err != nil {
t.Fatal("failed to extract DNS txid:", err)
}
// Respond with a CNAME record www.example.com -> cdn.example.net
// followed by the A record for cdn.example.net.
owner, err := dns.NewName(hostname)
if err != nil {
t.Fatal(err)
}
aliasName, err := dns.NewName(alias)
if err != nil {
t.Fatal(err)
}
aliasWire, err := aliasName.AppendTo(nil)
if err != nil {
t.Fatal(err)
}
msg := dns.Message{
Questions: []dns.Question{{
Name: owner,
Type: dns.TypeA,
Class: dns.ClassINET,
}},
Answers: []dns.Resource{
dns.NewResource(owner, dns.TypeCNAME, dns.ClassINET, 300, aliasWire),
dns.NewResource(aliasName, dns.TypeA, dns.ClassINET, 300, wantAddr.AsSlice()),
},
}
responsePkt, err := buildDNSMsgResponsePacket(t, txid, clientPort, msg,
dnsServerAddr, dnsServerMAC, clientAddr, clientMAC, buf[:])
if err != nil {
t.Fatal("failed to build CNAME response packet:", err)
}
if err = client.IngressEthernet(responsePkt); err != nil {
t.Fatal("client Demux of CNAME response failed:", err)
}
addrs, done, err := client.ResultLookupIP(hostname)
if err != nil {
t.Fatal("ResultLookupIP error:", err)
}
if !done {
t.Fatal("DNS lookup not done after receiving CNAME response")
}
if !slices.Contains(addrs, wantAddr) {
t.Errorf("expected address %s not found in result %v", wantAddr, addrs)
}
}