Frontend Labs
⋯

Part V — Network and HTTP services: the networking and HTTPS guides in Rust

This Part follows one request down the stack and back up, in the order of the HTTPS lifecycle guide: a DNS lookup, HTTP over a raw socket, a production server, the outbound clients that server calls, TLS, and finally a reverse proxy in front of all of it. Each chapter names the guide chapter it implements. Read that chapter for the protocol, and this one for the code.


45. A DNS client from raw bytes

Implements: Net Ch 18 and HTTPS Ch 2. The networking guide's first Go lab builds the same tool.

A DNS message is a 12-byte header, then questions, then resource records. Names are length-prefixed labels (3www7example3com0), and in answers they are usually compressed: a byte with the top two bits set (0xC0) is a pointer to an earlier offset in the message. That pointer is what makes parsing DNS dangerous. A malicious response can point a name at itself, and a naive parser loops forever. Several C resolvers have had CVEs from exactly this.

// src/bin/dnsq.rs   usage: dnsq NAME [A|AAAA|CNAME] [SERVER]
use std::net::{Ipv4Addr, Ipv6Addr, UdpSocket};
use std::time::Duration;

const TYPE_A: u16 = 1;
const TYPE_CNAME: u16 = 5;
const TYPE_AAAA: u16 = 28;

fn build_query(id: u16, name: &str, qtype: u16) -> Result<Vec<u8>, String> {
    let mut q = Vec::with_capacity(512);
    q.extend_from_slice(&id.to_be_bytes());
    q.extend_from_slice(&0x0100u16.to_be_bytes());  // flags: RD (recursion desired)
    q.extend_from_slice(&1u16.to_be_bytes());       // QDCOUNT = 1
    q.extend_from_slice(&[0u8; 6]);                 // ANCOUNT, NSCOUNT, ARCOUNT = 0
    for label in name.trim_end_matches('.').split('.') {
        let len = u8::try_from(label.len()).ok().filter(|&l| (1..=63).contains(&l))
            .ok_or_else(|| format!("bad label {label:?}"))?;
        q.push(len);
        q.extend_from_slice(label.as_bytes());
    }
    q.push(0);                                      // root label ends the name
    q.extend_from_slice(&qtype.to_be_bytes());
    q.extend_from_slice(&1u16.to_be_bytes());       // class IN
    Ok(q)
}

/// A bounds-checked cursor. Every read returns Err instead of panicking on short input.
struct Reader<'a> { buf: &'a [u8], pos: usize }

impl<'a> Reader<'a> {
    fn take(&mut self, n: usize) -> Result<&'a [u8], String> {
        let end = self.pos.checked_add(n).filter(|&e| e <= self.buf.len()).ok_or("truncated message")?;
        let s = &self.buf[self.pos..end];
        self.pos = end;
        Ok(s)
    }
    fn u16(&mut self) -> Result<u16, String> { Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap())) }
    fn u32(&mut self) -> Result<u32, String> { Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) }

    /// Reads a possibly-compressed name. Defends against pointer loops and oversized names.
    fn name(&mut self) -> Result<String, String> {
        let mut labels = Vec::new();
        let mut pos = self.pos;
        let mut jumped = false;
        let mut jumps = 0;
        let mut total = 0;
        loop {
            let len = *self.buf.get(pos).ok_or("name runs past end")? as usize;
            if len & 0xC0 == 0xC0 {                                   // compression pointer
                let lo = *self.buf.get(pos + 1).ok_or("truncated pointer")? as usize;
                if !jumped { self.pos = pos + 2; }                      // the cursor resumes after the FIRST pointer
                jumped = true;
                jumps += 1;
                if jumps > 16 { return Err("compression pointer loop".into()); }
                pos = ((len & 0x3F) << 8) | lo;
                continue;
            }
            if len == 0 {
                if !jumped { self.pos = pos + 1; }
                break;
            }
            let label = self.buf.get(pos + 1..pos + 1 + len).ok_or("label runs past end")?;
            total += len + 1;
            if total > 255 { return Err("name longer than 255 bytes".into()); }
            labels.push(String::from_utf8_lossy(label).into_owned());
            pos += 1 + len;
        }
        Ok(labels.join("."))
    }
}

#[derive(Debug, PartialEq)]
enum Answer { A(Ipv4Addr), Aaaa(Ipv6Addr), Cname(String), Other(u16) }

fn parse_response(msg: &[u8], want_id: u16) -> Result<Vec<(String, u32, Answer)>, String> {
    let mut r = Reader { buf: msg, pos: 0 };
    let id = r.u16()?;
    if id != want_id { return Err(format!("ID mismatch: {id} != {want_id} (spoofed or stale reply)")); }
    let flags = r.u16()?;
    if flags & 0x8000 == 0 { return Err("not a response".into()); }
    if flags & 0x0200 != 0 { return Err("truncated (TC): retry over TCP".into()); }
    match flags & 0x000F {
        0 => {}
        3 => return Err("NXDOMAIN: name does not exist".into()),
        rcode => return Err(format!("server error, RCODE={rcode}")),
    }
    let (qd, an) = (r.u16()?, r.u16()?);
    r.take(4)?;                                       // skip NSCOUNT, ARCOUNT
    for _ in 0..qd { r.name()?; r.take(4)?; }          // skip the echoed questions

    let mut out = Vec::new();
    for _ in 0..an {
        let name = r.name()?;
        let (rtype, _class, ttl) = (r.u16()?, r.u16()?, r.u32()?);
        let rdlen = r.u16()? as usize;
        let rdata_start = r.pos;
        let rdata = r.take(rdlen)?;
        let ans = match (rtype, rdlen) {
            (TYPE_A, 4) => Answer::A(Ipv4Addr::from(<[u8; 4]>::try_from(rdata).unwrap())),
            (TYPE_AAAA, 16) => Answer::Aaaa(Ipv6Addr::from(<[u8; 16]>::try_from(rdata).unwrap())),
            (TYPE_CNAME, _) => Answer::Cname(Reader { buf: msg, pos: rdata_start }.name()?), // may point backwards
            (t, _) => Answer::Other(t),
        };
        out.push((name, ttl, ans));
    }
    Ok(out)
}

fn main() -> Result<(), String> {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let name = args.first().map(String::as_str).unwrap_or("example.com");
    let qtype = match args.get(1).map(String::as_str) { Some("AAAA") => TYPE_AAAA, Some("CNAME") => TYPE_CNAME, _ => TYPE_A };
    let server = args.get(2).map(String::as_str).unwrap_or("1.1.1.1:53");

    let id: u16 = rand::random();                     // random ID + random source port: anti-spoofing
    let query = build_query(id, name, qtype)?;
    let sock = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
    sock.set_read_timeout(Some(Duration::from_secs(3))).map_err(|e| e.to_string())?;
    sock.send_to(&query, server).map_err(|e| e.to_string())?;

    let mut buf = [0u8; 1232];                        // the EDNS-safe UDP size; classic DNS max is 512
    let (n, from) = sock.recv_from(&mut buf).map_err(|e| format!("no reply: {e}"))?;
    println!(";; {n} bytes from {from}, id {id}");
    for (owner, ttl, ans) in parse_response(&buf[..n], id)? {
        println!("{owner:<30} {ttl:>6}  {ans:?}");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn query_layout() {
        let q = build_query(0xBEEF, "a.bc", TYPE_A).unwrap();
        assert_eq!(&q[..2], &[0xBE, 0xEF]);
        assert_eq!(&q[12..], &[1, b'a', 2, b'b', b'c', 0, 0, 1, 0, 1]);
        assert!(build_query(1, &"x".repeat(64), TYPE_A).is_err());
    }

    #[test]
    fn rejects_pointer_loop() {
        // header (id=7, response, 1 answer) + an answer whose name points at itself (offset 12)
        let mut m = vec![0, 7, 0x81, 0x80, 0, 0, 0, 1, 0, 0, 0, 0];
        m.extend_from_slice(&[0xC0, 12]);
        assert_eq!(parse_response(&m, 7).unwrap_err(), "compression pointer loop");
    }

    #[test]
    fn rejects_truncated_garbage() {
        for len in 0..12 { assert!(parse_response(&[0xFF; 12][..len], 0xFFFF).is_err()); }
    }
}
cargo run --bin dnsq -- example.com
cargo run --bin dnsq -- www.github.com A 8.8.8.8:53   # a CNAME chain
cargo test --bin dnsq
;; 61 bytes from 1.1.1.1:53, id 61600
example.com                       112  A(104.20.23.154)
example.com                       112  A(172.66.147.243)
;; 62 bytes from 1.1.1.1:53, id 23339
www.github.com                   3581  Cname("github.com")
github.com                         41  A(20.207.73.82)

Your addresses, TTLs, and IDs will differ. Servers usually compress a CNAME target such as github.com into a pointer to the github.com suffix of the question. That is why the parser decodes CNAME data with a fresh Reader over the whole message, not just the record's bytes.

Look at what the safe parser buys you. Every read goes through take, which checks bounds with checked_add. Malformed input becomes an Err, never a panic or an out-of-bounds read. The two security checks are explicit and tested: the pointer-loop limit and the 255-byte name limit. The same parser in C needs every one of these checks done by hand, and DNS-parsing CVEs show what happens when one is missed.


Generated from real-life-rust-guide.md · 8 Oct 2026 16:36