dns: @hnw alternate proposal (#193)

* 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.

* 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

* dns: simplify @hnw function and additional fixes

---------

Co-authored-by: Yoshio HANAWA <y@hnw.jp>
This commit is contained in:
Pat Whittingslow
2026-08-28 21:39:12 -03:00
committed by GitHub
parent ab9d3ee691
commit 75f1e02a20
9 changed files with 369 additions and 56 deletions
+4 -2
View File
@@ -650,8 +650,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)
}
}