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:
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
115
tests/soa_compression_bug.rs
Normal file
115
tests/soa_compression_bug.rs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user