Frontend Labs
⋯

50. A reverse proxy with Hyper, and request-smuggling defenses

Implements: HTTPS Ch 8, Ch 20, and Go §39.

A reverse proxy is where framing bugs and identity bugs meet. This one uses Hyper directly, the library under Axum, reqwest, and Tonic, so you can see the connection-level settings that frameworks hide.

// src/bin/revproxy.rs
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::body::{Bytes, Incoming};
use hyper::header::{HeaderName, HeaderValue};
use hyper::{Request, Response, StatusCode, Uri};
use hyper_util::client::legacy::{connect::HttpConnector, Client};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

type Body = BoxBody<Bytes, hyper::Error>;

// RFC 9110 §7.6.1: these describe ONE hop and must never be forwarded.
const HOP_BY_HOP: [&str; 8] = ["connection", "keep-alive", "proxy-connection", "te", "trailer",
                               "transfer-encoding", "upgrade", "proxy-authorization"];

fn text(status: StatusCode, msg: &'static str) -> Response<Body> {
    let mut r = Response::new(Full::new(Bytes::from(msg)).map_err(|never| match never {}).boxed());
    *r.status_mut() = status;
    r
}

async fn proxy(mut req: Request<Incoming>, peer: SocketAddr, upstream: &'static str,
               client: Client<HttpConnector, Incoming>) -> Result<Response<Body>, hyper::Error> {
    // 1. Remove hop-by-hop headers, including any the client NAMED in its Connection header.
    let named: Vec<HeaderName> = req.headers().get_all("connection").iter()
        .filter_map(|v| v.to_str().ok())
        .flat_map(|v| v.split(',').map(|s| s.trim().to_ascii_lowercase()))
        .filter_map(|s| HeaderName::from_bytes(s.as_bytes()).ok())
        .collect();
    for h in HOP_BY_HOP.iter().map(|h| HeaderName::from_static(h)).chain(named) {
        req.headers_mut().remove(h);
    }

    // 2. Identity: this proxy is the EDGE, so client-supplied forwarding headers are lies. Overwrite them.
    req.headers_mut().remove("forwarded");
    req.headers_mut().insert("x-forwarded-for", HeaderValue::from_str(&peer.ip().to_string()).unwrap());
    req.headers_mut().insert("x-forwarded-proto", HeaderValue::from_static("http"));

    // 3. Point the request at the upstream.
    let path = req.uri().path_and_query().map_or("/", |p| p.as_str());
    *req.uri_mut() = match format!("http://{upstream}{path}").parse::<Uri>() {
        Ok(u) => u,
        Err(_) => return Ok(text(StatusCode::BAD_REQUEST, "bad uri")),
    };

    // 4. Deadline for the upstream. The body streams through; nothing is buffered whole.
    match tokio::time::timeout(Duration::from_secs(10), client.request(req)).await {
        Ok(Ok(resp)) => {
            let (mut parts, body) = resp.into_parts();
            for h in HOP_BY_HOP { parts.headers.remove(h); }
            Ok(Response::from_parts(parts, body.boxed()))
        }
        Ok(Err(_)) => Ok(text(StatusCode::BAD_GATEWAY, "upstream error")),
        Err(_) => Ok(text(StatusCode::GATEWAY_TIMEOUT, "upstream timeout")),
    }
}

async fn backend(listener: TcpListener) {
    // A tiny upstream that reports what it received.
    loop {
        let (tcp, _) = listener.accept().await.unwrap();
        tokio::spawn(async move {
            let svc = hyper::service::service_fn(|req: Request<Incoming>| async move {
                let xff = req.headers().get("x-forwarded-for").cloned();
                let secret = req.headers().contains_key("x-internal-secret");
                let len = req.into_body().collect().await?.to_bytes().len();
                let msg = format!("backend: xff={xff:?} body={len}B internal-secret-leaked={secret}");
                Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from(msg))))
            });
            let _ = hyper::server::conn::http1::Builder::new().serve_connection(TokioIo::new(tcp), svc).await;
        });
    }
}

async fn raw(addr: SocketAddr, request: &str) -> String {
    let mut s = TcpStream::connect(addr).await.unwrap();
    s.write_all(request.as_bytes()).await.unwrap();
    let mut out = Vec::new();
    let _ = tokio::time::timeout(Duration::from_secs(2), s.read_to_end(&mut out)).await;
    let text = String::from_utf8_lossy(&out);
    let status = text.lines().next().unwrap_or("<no response>").to_string();
    let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string();
    format!("{status} | {body}")
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let be = TcpListener::bind("127.0.0.1:0").await?;
    let upstream: &'static str = Box::leak(be.local_addr()?.to_string().into_boxed_str());
    tokio::spawn(backend(be));

    let client: Client<HttpConnector, Incoming> = Client::builder(TokioExecutor::new())
        .pool_idle_timeout(Duration::from_secs(30))
        .build_http();
    let front = TcpListener::bind("127.0.0.1:0").await?;
    let front_addr = front.local_addr()?;
    tokio::spawn(async move {
        loop {
            let (tcp, peer) = front.accept().await.unwrap();
            let client = client.clone();
            tokio::spawn(async move {
                let svc = hyper::service::service_fn(move |req| proxy(req, peer, upstream, client.clone()));
                let _ = hyper::server::conn::http1::Builder::new()
                    .timer(TokioTimer::new())
                    .header_read_timeout(Duration::from_secs(5))  // Slowloris defense, which axum::serve lacks
                    .max_buf_size(16 * 1024)                      // caps the request head
                    .serve_connection(TokioIo::new(tcp), svc)
                    .await;
            });
        }
    });

    println!("spoofed XFF:  {}", raw(front_addr, "GET / HTTP/1.1\r\nHost: x\r\nX-Forwarded-For: 10.0.0.1\r\nConnection: close\r\n\r\n").await);
    println!("Connection-named header: {}", raw(front_addr,
        "GET / HTTP/1.1\r\nHost: x\r\nX-Internal-Secret: 1\r\nConnection: close, X-Internal-Secret\r\n\r\n").await);
    println!("CL + TE:      {}", raw(front_addr,
        "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\nGET /admin HTTP/1.1\r\n\r\n").await);
    println!("bad CL:       {}", raw(front_addr, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5, 6\r\n\r\nhello!").await);
    println!("space-colon:  {}", raw(front_addr, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length : 5\r\n\r\nhello").await);
    Ok(())
}

The output of the demo, run as written:

spoofed XFF:  HTTP/1.1 200 OK | backend: xff=Some("127.0.0.1") body=0B internal-secret-leaked=false
Connection-named header: HTTP/1.1 200 OK | backend: xff=Some("127.0.0.1") body=0B internal-secret-leaked=false
CL + TE:      HTTP/1.1 200 OK | backend: xff=Some("127.0.0.1") body=0B internal-secret-leaked=false
bad CL:       HTTP/1.1 400 Bad Request |
space-colon:  HTTP/1.1 400 Bad Request |

Read the CL + TE line carefully. Hyper did not reject the request. RFC 9112 §6.3 says that when both headers are present, Transfer-Encoding wins and Content-Length must be ignored. Hyper followed that rule: it read a zero-length chunked body, and because the request said Connection: close, it never read the smuggled GET /admin that followed. The proxy then forwarded the request with its own framing. The hop-by-hop Transfer-Encoding was stripped, and Hyper's client re-framed the body. So the backend never sees the ambiguous message, and both parsers agree. That is the real defense against desync: terminate and re-frame at every hop, with one strict parser. Proxies that pass raw bytes through, or that use two different parsers, are the ones that get smuggled through. Some proxies go further and reject CL+TE outright with 400, as Ch 46 does. Both behaviors are allowed by the RFC. Forwarding the ambiguous message unchanged is not.

The other rows show the remaining controls. The client's X-Forwarded-For: 10.0.0.1 was replaced, so the backend sees the real peer, and an IP allow-list based on XFF cannot be bypassed through this proxy (HTTPS Ch 20). The header the client listed in Connection: was stripped, which closes a known trick for deleting a header that a proxy adds and the backend trusts. Malformed lengths and whitespace before the colon get a 400 from Hyper's parser before your code runs.

Stretch goals: add a per-client-IP token-bucket rate limiter (Security in Depth Ch 40B), round-robin over several upstreams with passive health checks (HTTPS Ch 9), and terminate TLS using the acceptor from Ch 49.


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