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

Merged
razvandimescu merged 1 commits from fix/soa-compression-roundtrip into main 2026-04-24 18:59:57 +08:00
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.extend(type_bitmap);
rdata 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::UNKNOWN { data, .. } => data.clone(),
DnsRecord::RRSIG { .. } => Vec::new(), DnsRecord::RRSIG { .. } => Vec::new(),
} }

View File

@@ -24,6 +24,17 @@ pub enum DnsRecord {
host: String, host: String,
ttl: u32, ttl: u32,
}, },
SOA {
domain: String,
mname: String,
rname: String,
serial: u32,
refresh: u32,
retry: u32,
expire: u32,
minimum: u32,
ttl: u32,
},
CNAME { CNAME {
domain: String, domain: String,
host: String, host: String,
@@ -100,6 +111,7 @@ impl DnsRecord {
| DnsRecord::RRSIG { domain, .. } | DnsRecord::RRSIG { domain, .. }
| DnsRecord::NSEC { domain, .. } | DnsRecord::NSEC { domain, .. }
| DnsRecord::NSEC3 { domain, .. } | DnsRecord::NSEC3 { domain, .. }
| DnsRecord::SOA { domain, .. }
| DnsRecord::UNKNOWN { domain, .. } => domain, | DnsRecord::UNKNOWN { domain, .. } => domain,
} }
} }
@@ -111,6 +123,7 @@ impl DnsRecord {
DnsRecord::NS { .. } => QueryType::NS, DnsRecord::NS { .. } => QueryType::NS,
DnsRecord::CNAME { .. } => QueryType::CNAME, DnsRecord::CNAME { .. } => QueryType::CNAME,
DnsRecord::MX { .. } => QueryType::MX, DnsRecord::MX { .. } => QueryType::MX,
DnsRecord::SOA { .. } => QueryType::SOA,
DnsRecord::DNSKEY { .. } => QueryType::DNSKEY, DnsRecord::DNSKEY { .. } => QueryType::DNSKEY,
DnsRecord::DS { .. } => QueryType::DS, DnsRecord::DS { .. } => QueryType::DS,
DnsRecord::RRSIG { .. } => QueryType::RRSIG, DnsRecord::RRSIG { .. } => QueryType::RRSIG,
@@ -132,6 +145,7 @@ impl DnsRecord {
| DnsRecord::RRSIG { ttl, .. } | DnsRecord::RRSIG { ttl, .. }
| DnsRecord::NSEC { ttl, .. } | DnsRecord::NSEC { ttl, .. }
| DnsRecord::NSEC3 { ttl, .. } | DnsRecord::NSEC3 { ttl, .. }
| DnsRecord::SOA { ttl, .. }
| DnsRecord::UNKNOWN { ttl, .. } => *ttl, | DnsRecord::UNKNOWN { ttl, .. } => *ttl,
} }
} }
@@ -172,6 +186,12 @@ impl DnsRecord {
+ next_hashed_owner.capacity() + next_hashed_owner.capacity()
+ type_bitmap.capacity() + type_bitmap.capacity()
} }
DnsRecord::SOA {
domain,
mname,
rname,
..
} => domain.capacity() + mname.capacity() + rname.capacity(),
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(), DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
} }
} }
@@ -188,6 +208,7 @@ impl DnsRecord {
| DnsRecord::RRSIG { ttl, .. } | DnsRecord::RRSIG { ttl, .. }
| DnsRecord::NSEC { ttl, .. } | DnsRecord::NSEC { ttl, .. }
| DnsRecord::NSEC3 { ttl, .. } | DnsRecord::NSEC3 { ttl, .. }
| DnsRecord::SOA { ttl, .. }
| DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl, | DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl,
} }
} }
@@ -365,8 +386,31 @@ impl DnsRecord {
ttl, 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(); let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec();
buffer.step(data_len as usize)?; buffer.step(data_len as usize)?;
Ok(DnsRecord::UNKNOWN { Ok(DnsRecord::UNKNOWN {
@@ -430,6 +474,30 @@ impl DnsRecord {
let size = buffer.pos() - (pos + 2); let size = buffer.pos() - (pos + 2);
buffer.set_u16(pos, size as u16)?; 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 { DnsRecord::AAAA {
ref domain, ref domain,
ref addr, ref addr,

View File

@@ -0,0 +1,115 @@
//! Regression test for issue #128: SOA with compressed MNAME/RNAME must
//! survive Numa's round-trip — compression pointers reference the upstream
//! packet's byte layout, so we have to decompress on read and re-compress
//! on write.
use numa::buffer::BytePacketBuffer;
use numa::packet::DnsPacket;
const COMPRESSION_FLAG: u16 = 0xC000;
fn upstream_packet() -> Vec<u8> {
let mut p = Vec::<u8>::new();
p.extend_from_slice(&[
0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00,
]);
assert_eq!(p.len(), 12);
write_name(&mut p, &["odin", "adobe", "com"]);
p.extend_from_slice(&[0x00, 0x41, 0x00, 0x01]);
p.extend_from_slice(&[0xC0, 0x0C]);
p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]);
let rdlen_pos_1 = p.len();
p.extend_from_slice(&[0x00, 0x00]);
let cname1_start = p.len();
write_name(&mut p, &["cdn", "adobeaemcloud", "com"]);
let rdlen_1 = (p.len() - cname1_start) as u16;
p[rdlen_pos_1..rdlen_pos_1 + 2].copy_from_slice(&rdlen_1.to_be_bytes());
p.extend_from_slice(&(COMPRESSION_FLAG | cname1_start as u16).to_be_bytes());
p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]);
let rdlen_pos_2 = p.len();
p.extend_from_slice(&[0x00, 0x00]);
let cname2_start = p.len();
p.push(9);
p.extend_from_slice(b"adobe-aem");
let map_label_off = p.len();
p.push(3);
p.extend_from_slice(b"map");
let fastly_label_off = p.len();
p.push(6);
p.extend_from_slice(b"fastly");
p.push(3);
p.extend_from_slice(b"net");
p.push(0);
let rdlen_2 = (p.len() - cname2_start) as u16;
p[rdlen_pos_2..rdlen_pos_2 + 2].copy_from_slice(&rdlen_2.to_be_bytes());
p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes());
p.extend_from_slice(&[0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x07, 0x08]);
let rdlen_pos_soa = p.len();
p.extend_from_slice(&[0x00, 0x00]);
let soa_rdata_start = p.len();
p.extend_from_slice(&(COMPRESSION_FLAG | map_label_off as u16).to_be_bytes());
p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes());
p.extend_from_slice(&1u32.to_be_bytes());
p.extend_from_slice(&7200u32.to_be_bytes());
p.extend_from_slice(&3600u32.to_be_bytes());
p.extend_from_slice(&1209600u32.to_be_bytes());
p.extend_from_slice(&1800u32.to_be_bytes());
let rdlen_soa = (p.len() - soa_rdata_start) as u16;
p[rdlen_pos_soa..rdlen_pos_soa + 2].copy_from_slice(&rdlen_soa.to_be_bytes());
p
}
fn write_name(p: &mut Vec<u8>, labels: &[&str]) {
for l in labels {
p.push(l.len() as u8);
p.extend_from_slice(l.as_bytes());
}
p.push(0);
}
#[test]
fn compressed_soa_survives_numa_round_trip() {
let upstream = upstream_packet();
let hickory_in = hickory_proto::op::Message::from_vec(&upstream)
.expect("hand-crafted upstream must be valid");
let soa_in_rd = hickory_in.name_servers()[0]
.data()
.clone()
.into_soa()
.expect("SOA rdata");
assert_eq!(soa_in_rd.mname().to_string(), "map.fastly.net.");
assert_eq!(soa_in_rd.rname().to_string(), "fastly.net.");
let mut in_buf = BytePacketBuffer::from_bytes(&upstream);
let pkt = DnsPacket::from_buffer(&mut in_buf).expect("numa parses upstream");
assert_eq!(pkt.answers.len(), 2);
assert_eq!(pkt.authorities.len(), 1);
let mut out_buf = BytePacketBuffer::new();
pkt.write(&mut out_buf).expect("numa writes");
let out = out_buf.filled().to_vec();
let hickory_out =
hickory_proto::op::Message::from_vec(&out).expect("numa re-emission must parse strictly");
let soa_out_rd = hickory_out.name_servers()[0]
.data()
.clone()
.into_soa()
.expect("SOA rdata on output");
assert_eq!(soa_out_rd.mname().to_string(), "map.fastly.net.");
assert_eq!(soa_out_rd.rname().to_string(), "fastly.net.");
assert_eq!(soa_out_rd.serial(), 1);
assert_eq!(soa_out_rd.refresh(), 7200);
assert_eq!(soa_out_rd.retry(), 3600);
assert_eq!(soa_out_rd.expire(), 1209600);
assert_eq!(soa_out_rd.minimum(), 1800);
}