Frontend Labs
⋯

46. HTTP/1.1 from a bare TcpListener

Implements: Net Ch 28, HTTPS Ch 5, and the C guide's §21.2, with the limits a real server needs.

You will use a framework (next chapter). Writing HTTP/1.1 by hand once shows you what every framework does about message framing. Framing mistakes are behind request smuggling, which Ch 50 covers.

// src/bin/tinyhttp.rs
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::time::Duration;

const MAX_HEAD: u64 = 8 * 1024;      // request line + headers
const MAX_BODY: usize = 1024 * 1024;

struct Request { method: String, path: String, headers: Vec<(String, String)>, body: Vec<u8>, keep_alive: bool }

enum HttpError { BadRequest(&'static str), HeadTooLarge, BodyTooLarge, NotImplemented(&'static str), Closed }

impl Request {
    fn header(&self, name: &str) -> Option<&str> {
        self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
    }
}

fn read_request(reader: &mut BufReader<TcpStream>) -> Result<Request, HttpError> {
    let mut budget = MAX_HEAD;
    let mut read_line = |reader: &mut BufReader<TcpStream>| -> Result<String, HttpError> {
        let mut line = String::new();
        let n = reader.by_ref().take(budget).read_line(&mut line).map_err(|_| HttpError::Closed)?;
        if n == 0 { return Err(HttpError::Closed); }
        if !line.ends_with('\n') { return Err(HttpError::HeadTooLarge); } // budget ran out mid-line
        budget -= n as u64;
        Ok(line.trim_end_matches(['\r', '\n']).to_string())
    };

    let request_line = read_line(reader)?;
    let mut parts = request_line.split(' ');
    let (method, path, version) = match (parts.next(), parts.next(), parts.next(), parts.next()) {
        (Some(m), Some(p), Some(v), None) if p.starts_with('/') => (m.to_string(), p.to_string(), v),
        _ => return Err(HttpError::BadRequest("malformed request line")),
    };
    if version != "HTTP/1.1" && version != "HTTP/1.0" { return Err(HttpError::BadRequest("bad version")); }

    let mut headers = Vec::new();
    loop {
        let line = read_line(reader)?;
        if line.is_empty() { break; }                                  // blank line ends the head
        let (k, v) = line.split_once(':').ok_or(HttpError::BadRequest("header without ':'"))?;
        if k.is_empty() || k.ends_with([' ', '\t']) {                  // "Content-Length : 5" is a smuggling trick
            return Err(HttpError::BadRequest("whitespace before ':'"));
        }
        headers.push((k.to_string(), v.trim().to_string()));
    }
    let mut req = Request { method, path, headers, body: Vec::new(), keep_alive: version == "HTTP/1.1" };
    if let Some(c) = req.header("connection") {
        req.keep_alive = !c.eq_ignore_ascii_case("close") && (version == "HTTP/1.1" || c.eq_ignore_ascii_case("keep-alive"));
    }

    // FRAMING: exactly one way to know where the body ends.
    let te = req.header("transfer-encoding").is_some();
    let cls: Vec<&str> = req.headers.iter().filter(|(k, _)| k.eq_ignore_ascii_case("content-length")).map(|(_, v)| v.as_str()).collect();
    if te && !cls.is_empty() { return Err(HttpError::BadRequest("both Transfer-Encoding and Content-Length")); }
    if te { return Err(HttpError::NotImplemented("chunked bodies")); }
    if cls.len() > 1 { return Err(HttpError::BadRequest("multiple Content-Length headers")); }
    if let Some(cl) = cls.first() {
        if cl.is_empty() || !cl.bytes().all(|b| b.is_ascii_digit()) { return Err(HttpError::BadRequest("bad Content-Length")); }
        let len: usize = cl.parse().map_err(|_| HttpError::BodyTooLarge)?;
        if len > MAX_BODY { return Err(HttpError::BodyTooLarge); }
        req.body = vec![0; len];
        reader.read_exact(&mut req.body).map_err(|_| HttpError::Closed)?;
    }
    Ok(req)
}

fn respond(w: &mut TcpStream, status: &str, body: &[u8], keep_alive: bool) -> std::io::Result<()> {
    let conn = if keep_alive { "keep-alive" } else { "close" };
    write!(w, "HTTP/1.1 {status}\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: {conn}\r\n\r\n", body.len())?;
    w.write_all(body)
}

/// Closing a socket that still has unread input makes the kernel send RST, and the RST can
/// destroy the response before the client reads it. Half-close, then drain briefly (nginx's "lingering close").
fn lingering_close(writer: &mut TcpStream, mut reader: BufReader<TcpStream>) -> std::io::Result<()> {
    writer.shutdown(std::net::Shutdown::Write)?;                     // send FIN after the response
    reader.get_ref().set_read_timeout(Some(Duration::from_secs(2)))?;
    let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink()); // discard what is left
    Ok(())
}

fn handle(stream: TcpStream) -> std::io::Result<()> {
    stream.set_read_timeout(Some(Duration::from_secs(10)))?;          // Slowloris defense (HTTPS Ch 19)
    let mut writer = stream.try_clone()?;
    let mut reader = BufReader::new(stream);
    loop {                                                             // keep-alive: many requests per connection
        let req = match read_request(&mut reader) {
            Ok(r) => r,
            Err(HttpError::Closed) => return Ok(()),
            Err(e) => {
                let (status, msg) = match e {
                    HttpError::BadRequest(m) => ("400 Bad Request", m),
                    HttpError::HeadTooLarge => ("431 Request Header Fields Too Large", "head too large"),
                    HttpError::BodyTooLarge => ("413 Content Too Large", "body too large"),
                    HttpError::NotImplemented(m) => ("501 Not Implemented", m),
                    HttpError::Closed => unreachable!(),
                };
                // After a framing error we cannot know where the next request starts: always close.
                respond(&mut writer, status, msg.as_bytes(), false)?;
                return lingering_close(&mut writer, reader);
            }
        };
        let (status, body) = match (req.method.as_str(), req.path.as_str()) {
            ("GET", "/") => ("200 OK", b"hello from tinyhttp\n".to_vec()),
            ("POST", "/echo") => ("200 OK", req.body.clone()),
            (_, "/" | "/echo") => ("405 Method Not Allowed", b"method not allowed\n".to_vec()),
            _ => ("404 Not Found", b"not found\n".to_vec()),
        };
        respond(&mut writer, status, &body, req.keep_alive)?;
        if !req.keep_alive { return Ok(()); }
    }
}

fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:0")?;
    let addr = listener.local_addr()?;
    std::thread::spawn(move || {
        for s in listener.incoming().flatten() {
            std::thread::spawn(move || { let _ = handle(s); });
        }
    });

    // A raw client: send bytes exactly as written, print the status line of each response.
    let send = |raw: &str| -> std::io::Result<Vec<String>> {
        let mut s = TcpStream::connect(addr)?;
        s.write_all(raw.as_bytes())?;
        s.shutdown(std::net::Shutdown::Write)?;
        let mut out = String::new();
        s.read_to_string(&mut out)?;
        Ok(out.lines().filter(|l| l.starts_with("HTTP/1.1")).map(String::from).collect())
    };
    println!("pipelined:  {:?}", send("GET / HTTP/1.1\r\nHost: x\r\n\r\nPOST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")?);
    println!("smuggling:  {:?}", send("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n")?);
    println!("dup CL:     {:?}", send("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\nhello!")?);
    println!("space-colon:{:?}", send("POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length : 5\r\n\r\nhello")?);
    println!("huge head:  {:?}", send(&format!("GET / HTTP/1.1\r\nX-Big: {}\r\n\r\n", "a".repeat(10_000)))?);
    Ok(())
}
pipelined:  ["HTTP/1.1 200 OK", "HTTP/1.1 200 OK"]
smuggling:  ["HTTP/1.1 400 Bad Request"]
dup CL:     ["HTTP/1.1 400 Bad Request"]
space-colon:["HTTP/1.1 400 Bad Request"]
huge head:  ["HTTP/1.1 431 Request Header Fields Too Large"]

Why lingering_close exists. The first version of this server simply returned after sending the 431. The "huge head" client then failed with Connection reset by peer and never saw the status line. The server had closed its socket with about 2 KB of the client's request still unread in the receive buffer. In that situation the kernel sends RST instead of FIN, and a RST tells the peer to throw away any data it has not read yet, including our response (Net Ch 23). Any server that rejects a request before reading all of it must half-close and drain, as above.

Each rejection above is a real request-smuggling technique (Security in Depth Ch 30). Smuggling works when two HTTP parsers, usually a proxy and a backend, disagree about where one request ends. The defense is to be strict: one framing method, no duplicates, no whitespace before the colon, and close the connection after any framing error. Rust's types do not give you this. These are protocol rules, and you have to implement them. What Rust does guarantee is that none of these hostile inputs can overflow a buffer.


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