fix(packet): parse SOA natively to stop malformed replies (#128)

SOA records were stored as opaque bytes (DnsRecord::UNKNOWN), so the
RFC 1035 §3.3.13 MNAME/RNAME name-compression pointers — offsets into
the upstream packet — were re-emitted verbatim. Once Numa applied its
own compression to surrounding names, those pointers landed on garbage
and clients rejected the reply ("malformed reply packet" in kdig).

Parse SOA via read_qname and write via write_qname, matching the
NS/CNAME/MX pattern. Adds the canonical-rdata arm in dnssec.rs for
RRSIG verification. Regression test round-trips a CNAME-chain response
with a compressed SOA in authority through hickory-proto strict parse.
This commit is contained in:
Razvan Dimescu
2026-04-23 00:35:41 +03:00
parent c787de1548
commit 2274151c17
3 changed files with 206 additions and 1 deletions

View File

@@ -882,6 +882,28 @@ fn record_rdata_canonical(record: &DnsRecord) -> Vec<u8> {
rdata.extend(type_bitmap);
rdata
}
DnsRecord::SOA {
mname,
rname,
serial,
refresh,
retry,
expire,
minimum,
..
} => {
let mname_wire = name_to_wire(mname);
let rname_wire = name_to_wire(rname);
let mut rdata = Vec::with_capacity(mname_wire.len() + rname_wire.len() + 20);
rdata.extend(&mname_wire);
rdata.extend(&rname_wire);
rdata.extend(&serial.to_be_bytes());
rdata.extend(&refresh.to_be_bytes());
rdata.extend(&retry.to_be_bytes());
rdata.extend(&expire.to_be_bytes());
rdata.extend(&minimum.to_be_bytes());
rdata
}
DnsRecord::UNKNOWN { data, .. } => data.clone(),
DnsRecord::RRSIG { .. } => Vec::new(),
}

View File

@@ -24,6 +24,17 @@ pub enum DnsRecord {
host: String,
ttl: u32,
},
SOA {
domain: String,
mname: String,
rname: String,
serial: u32,
refresh: u32,
retry: u32,
expire: u32,
minimum: u32,
ttl: u32,
},
CNAME {
domain: String,
host: String,
@@ -100,6 +111,7 @@ impl DnsRecord {
| DnsRecord::RRSIG { domain, .. }
| DnsRecord::NSEC { domain, .. }
| DnsRecord::NSEC3 { domain, .. }
| DnsRecord::SOA { domain, .. }
| DnsRecord::UNKNOWN { domain, .. } => domain,
}
}
@@ -111,6 +123,7 @@ impl DnsRecord {
DnsRecord::NS { .. } => QueryType::NS,
DnsRecord::CNAME { .. } => QueryType::CNAME,
DnsRecord::MX { .. } => QueryType::MX,
DnsRecord::SOA { .. } => QueryType::SOA,
DnsRecord::DNSKEY { .. } => QueryType::DNSKEY,
DnsRecord::DS { .. } => QueryType::DS,
DnsRecord::RRSIG { .. } => QueryType::RRSIG,
@@ -132,6 +145,7 @@ impl DnsRecord {
| DnsRecord::RRSIG { ttl, .. }
| DnsRecord::NSEC { ttl, .. }
| DnsRecord::NSEC3 { ttl, .. }
| DnsRecord::SOA { ttl, .. }
| DnsRecord::UNKNOWN { ttl, .. } => *ttl,
}
}
@@ -172,6 +186,12 @@ impl DnsRecord {
+ next_hashed_owner.capacity()
+ type_bitmap.capacity()
}
DnsRecord::SOA {
domain,
mname,
rname,
..
} => domain.capacity() + mname.capacity() + rname.capacity(),
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
}
}
@@ -188,6 +208,7 @@ impl DnsRecord {
| DnsRecord::RRSIG { ttl, .. }
| DnsRecord::NSEC { ttl, .. }
| DnsRecord::NSEC3 { ttl, .. }
| DnsRecord::SOA { ttl, .. }
| DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl,
}
}
@@ -365,8 +386,31 @@ impl DnsRecord {
ttl,
})
}
QueryType::SOA => {
// MNAME/RNAME compressible per RFC 1035 §3.3.13 — decompress to avoid stale pointers on re-emit.
let mut mname = String::with_capacity(64);
buffer.read_qname(&mut mname)?;
let mut rname = String::with_capacity(64);
buffer.read_qname(&mut rname)?;
let serial = buffer.read_u32()?;
let refresh = buffer.read_u32()?;
let retry = buffer.read_u32()?;
let expire = buffer.read_u32()?;
let minimum = buffer.read_u32()?;
Ok(DnsRecord::SOA {
domain,
mname,
rname,
serial,
refresh,
retry,
expire,
minimum,
ttl,
})
}
_ => {
// SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively
// TXT, SRV, HTTPS, SVCB, etc. — stored as opaque bytes until parsed natively
let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec();
buffer.step(data_len as usize)?;
Ok(DnsRecord::UNKNOWN {
@@ -430,6 +474,30 @@ impl DnsRecord {
let size = buffer.pos() - (pos + 2);
buffer.set_u16(pos, size as u16)?;
}
DnsRecord::SOA {
ref domain,
ref mname,
ref rname,
serial,
refresh,
retry,
expire,
minimum,
ttl,
} => {
write_header(buffer, domain, QueryType::SOA.to_num(), ttl)?;
let rdlen_pos = buffer.pos();
buffer.write_u16(0)?;
buffer.write_qname(mname)?;
buffer.write_qname(rname)?;
buffer.write_u32(serial)?;
buffer.write_u32(refresh)?;
buffer.write_u32(retry)?;
buffer.write_u32(expire)?;
buffer.write_u32(minimum)?;
let rdlen = buffer.pos() - (rdlen_pos + 2);
buffer.set_u16(rdlen_pos, rdlen as u16)?;
}
DnsRecord::AAAA {
ref domain,
ref addr,