http-net 0.6.0

Sans-IO HTTP/1.1 server and client: strict RFC 9112 parsing, smuggling defences, bounded buffers, keep-alive, chunked bodies, upgrades, a blocking std adapter, no dependencies.
Documentation
//! T-35: credentials leaked through logs.
//!
//! Every `Debug` output of the crate replaces the values of
//! `authorization`, `proxy-authorization`, `cookie`, and `set-cookie` with
//! `<redacted N bytes>`, for every spelling of the field name, in both the
//! compact and the pretty form. Accessors keep returning the real values.

use http_net::{ClientConn, ClientEvent, Request, Response, ServerConn, ServerEvent};

/// The four credential fields, each in several spellings.
const SPELLINGS: &[&str] = &[
    "authorization",
    "Authorization",
    "AUTHORIZATION",
    "aUtHoRiZaTiOn",
    "proxy-authorization",
    "Proxy-Authorization",
    "PROXY-AUTHORIZATION",
    "cookie",
    "Cookie",
    "COOKIE",
    "cOoKiE",
    "set-cookie",
    "Set-Cookie",
    "SET-COOKIE",
    "sEt-CoOkIe",
];

/// A secret value that differs per field, so a leak names its source.
fn secret(name: &str) -> String {
    format!("s3cr3t-{}-0123456789", name.len())
}

/// Asserts `shown` (a `Debug` rendering) never contains `secret` and does
/// contain the redaction marker with the secret's length.
fn assert_redacted(what: &str, shown: &str, secret: &str) {
    assert!(!shown.contains(secret), "{what}: secret printed in {shown}");
    let marker = format!("<redacted {} bytes>", secret.len());
    assert!(shown.contains(&marker), "{what}: no {marker} in {shown}");
}

fn both(value: &impl std::fmt::Debug) -> [String; 2] {
    [format!("{value:?}"), format!("{value:#?}")]
}

fn request_with(name: &str, value: &str) -> Vec<u8> {
    format!("GET / HTTP/1.1\r\nHost: a\r\nX-Visible: shown\r\n{name}: {value}\r\n\r\n").into_bytes()
}

fn response_with(name: &str, value: &str) -> Vec<u8> {
    format!("HTTP/1.1 200 OK\r\nX-Visible: shown\r\n{name}: {value}\r\nContent-Length: 0\r\n\r\n")
        .into_bytes()
}

#[test]
fn test_request_head_headers_and_event_debug_redact_every_spelling() {
    for name in SPELLINGS {
        let value = secret(name);
        let mut conn = ServerConn::new();
        let _ = conn.feed(&request_with(name, &value));
        let event = conn.poll().unwrap();
        for shown in both(&event) {
            assert_redacted(&format!("ServerEvent::Request / {name}"), &shown, &value);
            assert!(
                shown.contains("shown"),
                "other fields stay visible: {shown}"
            );
        }
        let ServerEvent::Request(req) = event else {
            panic!("expected a request");
        };
        for shown in both(&req) {
            assert_redacted(&format!("RequestHead / {name}"), &shown, &value);
        }
        for shown in both(&req.headers()) {
            assert_redacted(&format!("Headers / {name}"), &shown, &value);
        }
        for shown in both(&req.header_all(name)) {
            assert_redacted(&format!("HeaderValues / {name}"), &shown, &value);
        }
        // Accessors still return the real bytes.
        assert_eq!(req.header(name), Some(value.as_bytes()));
        assert_eq!(req.header_all(name).next(), Some(value.as_bytes()));
        assert!(req.headers().any(|(_, v)| v == value.as_bytes()));
    }
}

#[test]
fn test_response_head_and_client_event_debug_redact_every_spelling() {
    for name in SPELLINGS {
        let value = secret(name);
        let mut conn = ClientConn::new();
        conn.send_request(&Request::get("/").header("host", "a"), &mut Vec::new())
            .unwrap();
        let _ = conn.feed(&response_with(name, &value));
        let event = conn.poll().unwrap();
        for shown in both(&event) {
            assert_redacted(&format!("ClientEvent::Response / {name}"), &shown, &value);
        }
        let ClientEvent::Response(resp) = event else {
            panic!("expected a response");
        };
        for shown in both(&resp) {
            assert_redacted(&format!("ResponseHead / {name}"), &shown, &value);
            assert!(shown.contains("shown"));
        }
        for shown in both(&resp.headers()) {
            assert_redacted(&format!("Headers / {name}"), &shown, &value);
        }
        for shown in both(&resp.header_all(name)) {
            assert_redacted(&format!("HeaderValues / {name}"), &shown, &value);
        }
        assert_eq!(resp.header(name), Some(value.as_bytes()));
    }
}

#[test]
fn test_builders_debug_redact_every_spelling() {
    for name in SPELLINGS {
        let value = secret(name);
        let request = Request::get("/")
            .header("host", "a")
            .header("x-visible", "shown")
            .header(name, &value);
        for shown in both(&request) {
            assert_redacted(&format!("Request / {name}"), &shown, &value);
            assert!(shown.contains("shown"));
        }
        assert_eq!(request.header_value(name), Some(value.as_bytes()));

        let response = Response::new(200)
            .header("x-visible", "shown")
            .header(name, &value);
        for shown in both(&response) {
            assert_redacted(&format!("Response / {name}"), &shown, &value);
        }
        assert_eq!(response.header_value(name), Some(value.as_bytes()));
    }
}

#[test]
fn test_repeated_credential_fields_are_each_redacted() {
    let mut conn = ServerConn::new();
    let _ = conn
        .feed(b"GET / HTTP/1.1\r\nHost: a\r\nCookie: first=aaaa\r\ncookie: second=bbbbbb\r\n\r\n");
    let ServerEvent::Request(req) = conn.poll().unwrap() else {
        panic!("expected a request");
    };
    for shown in both(&req)
        .into_iter()
        .chain(both(&req.header_all("cookie")))
    {
        assert!(
            !shown.contains("first=aaaa") && !shown.contains("second=bbbbbb"),
            "{shown}"
        );
        assert!(
            shown.contains("<redacted 10 bytes>") && shown.contains("<redacted 13 bytes>"),
            "{shown}"
        );
    }
}

#[test]
fn test_empty_credential_value_is_redacted_as_zero_bytes() {
    let mut conn = ServerConn::new();
    let _ = conn.feed(b"GET / HTTP/1.1\r\nHost: a\r\nAuthorization:\r\n\r\n");
    let ServerEvent::Request(req) = conn.poll().unwrap() else {
        panic!("expected a request");
    };
    assert!(format!("{req:?}").contains("<redacted 0 bytes>"));
}

#[cfg(feature = "std")]
#[test]
fn test_blocking_client_response_debug_redacts_set_cookie() {
    use http_net::blocking;

    let server = blocking::Server::bind("127.0.0.1:0").unwrap();
    let addr = server.local_addr();
    let stop = server.shutdown_handle();
    let thread = std::thread::spawn(move || {
        server.serve(|_req, _body| {
            Response::text(200, "ok")
                .header("Set-Cookie", "sid=s3cr3t-session-value; HttpOnly")
                .header("set-cookie", "csrf=s3cr3t-csrf-token")
                .header("x-visible", "shown")
        })
    });
    let resp = blocking::get(&format!("http://{addr}/")).unwrap();
    for shown in both(&resp) {
        assert!(!shown.contains("s3cr3t"), "secret printed in {shown}");
        assert!(shown.contains("<redacted 34 bytes>"), "{shown}");
        assert!(shown.contains("<redacted 22 bytes>"), "{shown}");
        assert!(shown.contains("shown"), "{shown}");
    }
    assert_eq!(
        resp.header("set-cookie"),
        Some(&b"sid=s3cr3t-session-value; HttpOnly"[..])
    );
    stop.shutdown();
    thread.join().unwrap().unwrap();
}