//! HTTP/3 carried by the engine, and shown to the filters as HTTP/1.1. //! //! The point of the translation is that a filter written once works on both: a pattern //! that blocks a path over TCP has to block the same path over QUIC, where on the wire //! that path was a QPACK-compressed header block. These tests put a real HTTP/3 client and //! a real HTTP/3 service either side of the relay and check what the chain was shown. use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use bytes::{Buf, Bytes}; use fgex_proxy::filter::{ChainHandle, FilterChain}; use fgex_proxy::proxy::ProxyStats; use fgex_proxy::proxy::Onward; use fgex_proxy::quic::{QuicConfig, QuicRelay, QuicSetup}; use fgex_proxy::spec::parse_filters; use fgex_proxy::tls; use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; fn self_signed() -> (String, String) { let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()]) .unwrap(); (cert.cert.pem(), cert.key_pair.serialize_pem()) } /// An HTTP/3 service that answers with what it was asked, body and all. async fn spawn_h3_echo(cert: &str, key: &str) -> SocketAddr { let mut config = (*tls::server_config(cert, key).unwrap()).clone(); config.alpn_protocols = vec![b"h3".to_vec()]; let endpoint = quinn::Endpoint::server( quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())), "127.0.0.1:0".parse().unwrap(), ) .unwrap(); let addr = endpoint.local_addr().unwrap(); tokio::spawn(async move { while let Some(incoming) = endpoint.accept().await { tokio::spawn(async move { let Ok(connection) = incoming.await else { return; }; let Ok(mut h3) = h3::server::builder() .build(h3_quinn::Connection::new(connection)) .await else { return; }; while let Ok(Some(resolver)) = h3.accept().await { tokio::spawn(async move { let Ok((request, mut stream)) = resolver.resolve_request().await else { return; }; let mut body = Vec::new(); while let Ok(Some(mut chunk)) = stream.recv_data().await { let len = chunk.remaining(); body.extend_from_slice(&chunk.copy_to_bytes(len)); } // One path answers with something a rule is looking for, and // says it in bytes that appear nowhere in the request: that is // what makes a test of the *answer* a test of the answer. let answer = if request.uri().path() == "/secret" { "here is FLAG{only-in-the-answer}".to_string() } else { format!( "you asked for {} and sent {} bytes: {}", request.uri().path(), body.len(), String::from_utf8_lossy(&body) ) }; let response = http::Response::builder() .status(http::StatusCode::OK) .header("content-length", answer.len().to_string()) .body(()) .unwrap(); let _ = stream.send_response(response).await; let _ = stream.send_data(Bytes::from(answer)).await; let _ = stream.finish().await; }); } }); } }); addr } /// A service that speaks HTTP/1.1 and nothing else, which is what most of the web is. /// /// Deliberately hand-written rather than a crate: what these tests are about is what /// leaves this engine, so the far end has to be something that reads exactly what an /// HTTP/1.1 service reads and nothing more forgiving. async fn spawn_h1_echo() -> SocketAddr { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { loop { let Ok((mut socket, _)) = listener.accept().await else { return; }; tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut buf = Vec::new(); let mut chunk = [0u8; 4096]; // The head, then whatever the framing says the body is. let head_end = loop { let n = match socket.read(&mut chunk).await { Ok(0) | Err(_) => return, Ok(n) => n, }; buf.extend_from_slice(&chunk[..n]); if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") { break at + 4; } }; let head = String::from_utf8_lossy(&buf[..head_end]).to_lowercase(); let path = head.split_whitespace().nth(1).unwrap_or("/").to_string(); let declared = head .split("content-length:") .nth(1) .and_then(|rest| rest.split("\r\n").next()) .and_then(|v| v.trim().parse::().ok()); let mut body = buf[head_end..].to_vec(); match declared { Some(want) => { while body.len() < want { let n = match socket.read(&mut chunk).await { Ok(0) | Err(_) => break, Ok(n) => n, }; body.extend_from_slice(&chunk[..n]); } body.truncate(want.min(body.len())); } // Chunked, which is what an undeclared body is in HTTP/1.1 — and what // the rendering shows a filter, so it is what has to arrive here. None if head.contains("transfer-encoding: chunked") => { let mut decoded = Vec::new(); loop { while !body.windows(2).any(|w| w == b"\r\n") { let n = match socket.read(&mut chunk).await { Ok(0) | Err(_) => break, Ok(n) => n, }; body.extend_from_slice(&chunk[..n]); } let Some(at) = body.windows(2).position(|w| w == b"\r\n") else { break; }; let size = usize::from_str_radix( String::from_utf8_lossy(&body[..at]).trim(), 16, ) .unwrap_or(0); if size == 0 { break; } body.drain(..at + 2); while body.len() < size + 2 { let n = match socket.read(&mut chunk).await { Ok(0) | Err(_) => break, Ok(n) => n, }; body.extend_from_slice(&chunk[..n]); } decoded.extend_from_slice(&body[..size.min(body.len())]); body.drain(..(size + 2).min(body.len())); } body = decoded; } None => body.clear(), } let answer = format!( "you asked for {path} and sent {} bytes: {}", body.len(), String::from_utf8_lossy(&body) ); let _ = socket .write_all( format!( "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}", answer.len() ) .as_bytes(), ) .await; let _ = socket.flush().await; }); } }); addr } async fn spawn_relay(upstream: SocketAddr, filters: &str) -> SocketAddr { spawn_relay_to(upstream, filters, Onward::Same).await } async fn spawn_relay_to( upstream: SocketAddr, filters: &str, protocol: Onward, ) -> SocketAddr { let (cert, key) = self_signed(); let chain = ChainHandle::new(FilterChain::new( parse_filters(filters).unwrap(), Duration::from_millis(500), )); let cfg = QuicConfig { setup: Arc::new(QuicSetup::build(&cert, &key).unwrap()), self_mark: None, spoof_source: false, max_connections: 0, over_limit_forwards: false, first_byte_timeout: None, connect_timeout: Duration::from_secs(5), upstream: protocol, // No interface to reconstruct anything onto, which is every host without the // capture device — what it would carry is pinned by the integration suite, which // has a real one. capture: None, }; let relay = QuicRelay::bind( "127.0.0.1:0".parse().unwrap(), upstream, chain, cfg, Arc::new(ProxyStats::default()), ) .unwrap(); let addr = relay.local_addr().unwrap(); tokio::spawn(Arc::new(relay).serve()); addr } /// One request through the relay, and what came back. async fn request( addr: SocketAddr, method: &str, path: &str, body: Option<&[u8]>, declare_length: bool, ) -> Result { let mut config = tls::quic_client_config().unwrap(); config.alpn_protocols = vec![b"h3".to_vec()]; let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( QuicClientConfig::try_from(config).unwrap(), ))); let connection = endpoint .connect(addr, "localhost") .map_err(|e| e.to_string())? .await .map_err(|e| e.to_string())?; let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) .await .map_err(|e| e.to_string())?; let driving = tokio::spawn(async move { let _ = driver.wait_idle().await; }); let answer = async { let mut builder = http::Request::builder() .method(method) .uri(format!("https://localhost{path}")); if declare_length { builder = builder.header("content-length", body.map(|b| b.len()).unwrap_or(0).to_string()); } let outgoing = builder.body(()).map_err(|e| e.to_string())?; let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; if let Some(body) = body { stream .send_data(Bytes::copy_from_slice(body)) .await .map_err(|e| e.to_string())?; } stream.finish().await.map_err(|e| e.to_string())?; stream.recv_response().await.map_err(|e| e.to_string())?; let mut got = Vec::new(); while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? { let len = chunk.remaining(); got.extend_from_slice(&chunk.copy_to_bytes(len)); } Ok(String::from_utf8_lossy(&got).into_owned()) } .await; driving.abort(); answer } #[tokio::test] async fn an_exchange_goes_through() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "").await; let answer = request(addr, "GET", "/hello", None, false).await.unwrap(); assert_eq!(answer, "you asked for /hello and sent 0 bytes: "); } /// The whole reason for rendering the exchange: a pattern written against an HTTP /// request matches one that arrived as a QPACK header block. #[tokio::test] async fn a_pattern_matches_the_request_line() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "block:GET /admin").await; let allowed = request(addr, "GET", "/public", None, false).await.unwrap(); assert!(allowed.contains("/public"), "an unrelated request was affected: {allowed}"); let refused = request(addr, "GET", "/admin", None, false).await; assert!( refused.is_err(), "a request the chain refused was answered anyway: {refused:?}" ); } /// A header the request did not carry must not appear in what the filters are shown. /// /// This is what the head is held back for. A bodiless request rendered as /// `transfer-encoding: chunked` would be a lie in front of every filter looking for that /// header — and a filter looking for it is usually looking for request smuggling, which /// makes a fabricated one the worst possible answer. #[tokio::test] async fn a_request_with_no_body_is_shown_no_framing_header() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "block:transfer-encoding").await; let answer = request(addr, "GET", "/plain", None, false).await.unwrap(); assert!(answer.contains("/plain"), "a framing header was invented: {answer}"); } /// And a body whose length was never declared is shown chunked, because that is what /// that message is in HTTP/1.1 — so a parser reading the rendering finds a body. #[tokio::test] async fn a_body_of_unknown_length_is_shown_chunked() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "block:transfer-encoding").await; let refused = request(addr, "POST", "/upload", Some(b"anything"), false).await; assert!( refused.is_err(), "a body with no declared length was shown unframed: {refused:?}" ); } /// A declared length is passed through as it was, and the body shown raw beneath it. #[tokio::test] async fn a_declared_length_is_left_alone() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "block:transfer-encoding").await; let answer = request(addr, "POST", "/upload", Some(b"declared"), true) .await .unwrap(); assert!(answer.contains("sent 8 bytes"), "the body did not arrive: {answer}"); } /// Both directions are inspected, and a refused answer does not reach the client. #[tokio::test] async fn the_answer_is_inspected_too() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "block:FLAG{").await; let answer = request(addr, "GET", "/harmless", None, false).await; assert!(answer.is_ok(), "an unrelated exchange was affected: {answer:?}"); // Nothing in this request carries the needle; everything that does is in the reply. let refused = request(addr, "GET", "/secret", None, false).await; match refused { Err(_) => {} Ok(body) => panic!("the answer reached the client anyway: {body}"), } } /// A service that answers a fixed body and remembers the trailer section it was sent. /// /// Separate from the echo above on purpose: what these tests are asking is what reached /// the *service*, which an answer that reflects the request cannot tell them apart from /// what reached the client. async fn spawn_h3_recorder( cert: &str, key: &str, ) -> (SocketAddr, Arc>>) { let seen: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); let mut config = (*tls::server_config(cert, key).unwrap()).clone(); config.alpn_protocols = vec![b"h3".to_vec()]; let endpoint = quinn::Endpoint::server( quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())), "127.0.0.1:0".parse().unwrap(), ) .unwrap(); let addr = endpoint.local_addr().unwrap(); let recorded = Arc::clone(&seen); tokio::spawn(async move { while let Some(incoming) = endpoint.accept().await { let recorded = Arc::clone(&recorded); tokio::spawn(async move { let Ok(connection) = incoming.await else { return }; let Ok(mut h3) = h3::server::builder() .build(h3_quinn::Connection::new(connection)) .await else { return; }; while let Ok(Some(resolver)) = h3.accept().await { let recorded = Arc::clone(&recorded); tokio::spawn(async move { let Ok((_request, mut stream)) = resolver.resolve_request().await else { return; }; while let Ok(Some(_)) = stream.recv_data().await {} if let Ok(Some(trailers)) = stream.recv_trailers().await { for (name, value) in trailers.iter() { recorded.lock().unwrap().push(format!( "{name}={}", value.to_str().unwrap_or("?") )); } } let answer = "recorded"; let response = http::Response::builder() .status(http::StatusCode::OK) .header("content-length", answer.len().to_string()) .body(()) .unwrap(); let _ = stream.send_response(response).await; let _ = stream.send_data(Bytes::from(answer)).await; let _ = stream.finish().await; }); } }); } }); (addr, seen) } /// A service that answers the moment the head arrives, without waiting for a body. /// /// The shape of a bidirectional RPC and of every service that replies before an upload /// finishes — a `413`, a redirect, a refusal. async fn spawn_h3_eager(cert: &str, key: &str) -> SocketAddr { let mut config = (*tls::server_config(cert, key).unwrap()).clone(); config.alpn_protocols = vec![b"h3".to_vec()]; let endpoint = quinn::Endpoint::server( quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())), "127.0.0.1:0".parse().unwrap(), ) .unwrap(); let addr = endpoint.local_addr().unwrap(); tokio::spawn(async move { while let Some(incoming) = endpoint.accept().await { tokio::spawn(async move { let Ok(connection) = incoming.await else { return }; let Ok(mut h3) = h3::server::builder() .build(h3_quinn::Connection::new(connection)) .await else { return; }; while let Ok(Some(resolver)) = h3.accept().await { tokio::spawn(async move { let Ok((_request, mut stream)) = resolver.resolve_request().await else { return; }; let answer = "the service spoke first"; let response = http::Response::builder() .status(http::StatusCode::OK) .header("content-length", answer.len().to_string()) .body(()) .unwrap(); let _ = stream.send_response(response).await; let _ = stream.send_data(Bytes::from(answer)).await; let _ = stream.finish().await; }); } }); } }); addr } /// One request that ends with a trailer section, and what came back. async fn request_with_trailer( addr: SocketAddr, body: &[u8], declare_length: bool, trailer: &str, ) -> Result { let mut config = tls::quic_client_config().unwrap(); config.alpn_protocols = vec![b"h3".to_vec()]; let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( QuicClientConfig::try_from(config).unwrap(), ))); let connection = endpoint .connect(addr, "localhost") .map_err(|e| e.to_string())? .await .map_err(|e| e.to_string())?; let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) .await .map_err(|e| e.to_string())?; let driving = tokio::spawn(async move { let _ = driver.wait_idle().await; }); let answer = async { let mut builder = http::Request::builder() .method("POST") .uri("https://localhost/up"); if declare_length { builder = builder.header("content-length", body.len().to_string()); } let outgoing = builder.body(()).map_err(|e| e.to_string())?; let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; if !body.is_empty() { stream .send_data(Bytes::copy_from_slice(body)) .await .map_err(|e| e.to_string())?; } let mut trailers = http::HeaderMap::new(); trailers.insert("x-note", http::HeaderValue::from_str(trailer).unwrap()); stream.send_trailers(trailers).await.map_err(|e| e.to_string())?; let _ = stream.finish().await; stream.recv_response().await.map_err(|e| e.to_string())?; let mut got = Vec::new(); while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? { let len = chunk.remaining(); got.extend_from_slice(&chunk.copy_to_bytes(len)); } Ok(String::from_utf8_lossy(&got).into_owned()) } .await; driving.abort(); answer } /// A trailer section on a message whose body was never framed by a declared length is /// shown to the chain, which is the only reason it may then be forwarded. /// /// The bodiless case is the one that matters most and the one that used to slip past: a /// message of HEADERS and TRAILERS and nothing in between — which is exactly a gRPC /// trailers-only answer — had no chunked body to hang a trailer section off, so the /// rendering ended at the head and the trailers travelled to the far side unread. They /// decide the framing now: in HTTP/1.1 a message with trailers *is* a chunked one. #[tokio::test] async fn a_trailer_section_is_shown_to_the_chain() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "block:SNEAKY").await; for (body, declared, what) in [ (&b""[..], false, "no body at all"), (&b"harmless"[..], false, "a body of undeclared length"), ] { let refused = request_with_trailer(addr, body, declared, "SNEAKY").await; assert!( refused.is_err(), "a needle in the trailers of a message with {what} was not seen: {refused:?}" ); } } /// And where the rendering cannot carry one, it is dropped rather than forwarded unread. /// /// A declared length means the HTTP/1.1 view of the message ends where the header said it /// would, so there is nowhere to put a trailer section — and forwarding a piece of a /// message no filter was shown is the one thing this engine does not do anywhere else. /// Dropping is what an intermediary is allowed to do with a trailer section it cannot /// carry; the exchange itself is untouched. #[tokio::test] async fn a_trailer_the_rendering_cannot_carry_is_not_forwarded() { let (cert, key) = self_signed(); let (upstream, seen) = spawn_h3_recorder(&cert, &key).await; let addr = spawn_relay(upstream, "").await; let answer = request_with_trailer(addr, b"harmless", true, "SNEAKY").await; assert_eq!(answer.as_deref(), Ok("recorded"), "the exchange was disturbed"); assert!( seen.lock().unwrap().is_empty(), "a trailer section no filter was shown reached the service: {:?}", seen.lock().unwrap() ); // Without the declared length the same trailers are renderable, so they are shown and // then carried: the rule is what the chain saw, not a dislike of trailers. let (upstream, seen) = spawn_h3_recorder(&cert, &key).await; let addr = spawn_relay(upstream, "").await; let answer = request_with_trailer(addr, b"harmless", false, "SNEAKY").await; assert_eq!(answer.as_deref(), Ok("recorded")); assert_eq!( seen.lock().unwrap().as_slice(), ["x-note=SNEAKY"], "a trailer section the chain accepted was dropped anyway" ); } /// An exchange the service is supposed to speak first in is not held waiting for a body. /// /// The head is held only until the framing is known, and a client that sends a head and /// then waits to be answered — a bidirectional RPC, gRPC's among them — never sends one. /// Holding it there deadlocked the exchange: the proxy waiting for a body, the client /// waiting for the answer that would tell it what to send. #[tokio::test] async fn a_service_that_speaks_first_is_not_waited_out() { let (cert, key) = self_signed(); let upstream = spawn_h3_eager(&cert, &key).await; let addr = spawn_relay(upstream, "").await; let mut config = tls::quic_client_config().unwrap(); config.alpn_protocols = vec![b"h3".to_vec()]; let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( QuicClientConfig::try_from(config).unwrap(), ))); let connection = endpoint.connect(addr, "localhost").unwrap().await.unwrap(); let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) .await .unwrap(); let driving = tokio::spawn(async move { let _ = driver.wait_idle().await; }); let outgoing = http::Request::builder() .method("POST") .uri("https://localhost/stream") .body(()) .unwrap(); let mut stream = sender.send_request(outgoing).await.unwrap(); // Deliberately no body and no finish: this client is waiting to be spoken to. let answered = tokio::time::timeout(Duration::from_secs(5), stream.recv_response()).await; driving.abort(); assert!( answered.is_ok(), "the head never reached a service that answers without waiting for a body" ); assert_eq!(answered.unwrap().unwrap().status(), http::StatusCode::OK); } // --- a service that speaks HTTP/1.1, behind an HTTP/3 edge ---------------------------- // // The one place in this engine where what leaves is not the version that arrived. It is // the operator's choice (`FGEX_PROXY_UPSTREAM`) and it exists because most of the web is // an HTTP/1.1 service that will never speak QUIC: firegex terminates HTTP/3 in front of // it and sends on the rendering the filters were already being shown. #[tokio::test] async fn an_http1_service_answers_an_http3_client() { let upstream = spawn_h1_echo().await; let addr = spawn_relay_to(upstream, "", Onward::Plain).await; let answer = request(addr, "GET", "/hello", None, false).await.unwrap(); assert_eq!(answer, "you asked for /hello and sent 0 bytes: "); } /// A body, declared — which is the framing that travels as a `content-length` and is /// read back by the far end as one. #[tokio::test] async fn a_declared_body_reaches_an_http1_service() { let upstream = spawn_h1_echo().await; let addr = spawn_relay_to(upstream, "", Onward::Plain).await; let answer = request(addr, "POST", "/upload", Some(b"twelve bytes"), true) .await .unwrap(); assert_eq!(answer, "you asked for /upload and sent 12 bytes: twelve bytes"); } /// And one that declared nothing, which is chunked in HTTP/1.1 — the framing the chain /// is shown, so it had better be the framing the service is sent. #[tokio::test] async fn an_undeclared_body_reaches_an_http1_service_chunked() { let upstream = spawn_h1_echo().await; let addr = spawn_relay_to(upstream, "", Onward::Plain).await; let answer = request(addr, "POST", "/upload", Some(b"twelve bytes"), false) .await .unwrap(); assert_eq!(answer, "you asked for /upload and sent 12 bytes: twelve bytes"); } /// The promise that makes the whole thing worth having: one filter file, and it does not /// care which version either end speaks. The same pattern that blocks this request /// against an HTTP/3 service blocks it against an HTTP/1.1 one. #[tokio::test] async fn a_pattern_matches_with_an_http1_service_behind() { let upstream = spawn_h1_echo().await; let addr = spawn_relay_to(upstream, "block:GET /admin", Onward::Plain).await; assert!( request(addr, "GET", "/admin", None, false).await.is_err(), "the request reached an HTTP/1.1 service that a pattern should have refused" ); let answer = request(addr, "GET", "/public", None, false).await.unwrap(); assert_eq!(answer, "you asked for /public and sent 0 bytes: "); } /// A `host` header that disagrees with `:authority` is malformed, and never reaches the /// service — the HTTP/3 half of the same case in `h2.rs`. #[tokio::test] async fn a_host_that_disagrees_with_the_authority_is_refused() { let (cert, key) = self_signed(); let upstream = spawn_h3_echo(&cert, &key).await; let addr = spawn_relay(upstream, "").await; let ask = |host: &'static str| async move { let mut config = tls::quic_client_config().unwrap(); config.alpn_protocols = vec![b"h3".to_vec()]; let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( QuicClientConfig::try_from(config).unwrap(), ))); let connection = endpoint .connect(addr, "localhost") .map_err(|e| e.to_string())? .await .map_err(|e| e.to_string())?; let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) .await .map_err(|e| e.to_string())?; let driving = tokio::spawn(async move { let _ = driver.wait_idle().await; }); let answer = async { let outgoing = http::Request::builder() .method("GET") .uri("https://localhost/who") .header("host", host) .body(()) .map_err(|e| e.to_string())?; let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; stream.finish().await.map_err(|e| e.to_string())?; stream.recv_response().await.map_err(|e| e.to_string())?; let mut got = Vec::new(); while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? { let len = chunk.remaining(); got.extend_from_slice(&chunk.copy_to_bytes(len)); } Ok::(String::from_utf8_lossy(&got).into_owned()) } .await; driving.abort(); answer }; let agreeing = ask("localhost").await; assert!( agreeing.as_deref().is_ok_and(|a| a.contains("/who")), "a host agreeing with :authority was not carried: {agreeing:?}" ); let disagreeing = tokio::time::timeout(Duration::from_secs(5), ask("admin.internal")) .await .expect("a malformed request was left hanging"); assert!(disagreeing.is_err(), "a disagreeing host was carried: {disagreeing:?}"); } /// One GET through the relay carrying `headers`, and the answer's status, head and body. async fn ask_with( addr: SocketAddr, headers: &[(&'static str, &'static str)], ) -> Result<(u16, http::HeaderMap, String), String> { let mut config = tls::quic_client_config().unwrap(); config.alpn_protocols = vec![b"h3".to_vec()]; let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( QuicClientConfig::try_from(config).unwrap(), ))); let connection = endpoint .connect(addr, "localhost") .map_err(|e| e.to_string())? .await .map_err(|e| e.to_string())?; let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) .await .map_err(|e| e.to_string())?; let driving = tokio::spawn(async move { let _ = driver.wait_idle().await; }); let answer = async { let mut builder = http::Request::builder().method("GET").uri("https://localhost/who"); for (name, value) in headers { builder = builder.header(*name, *value); } let outgoing = builder.body(()).map_err(|e| e.to_string())?; let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; stream.finish().await.map_err(|e| e.to_string())?; let response = stream.recv_response().await.map_err(|e| e.to_string())?; let mut got = Vec::new(); while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? { let len = chunk.remaining(); got.extend_from_slice(&chunk.copy_to_bytes(len)); } Ok::<_, String>(( response.status().as_u16(), response.headers().clone(), String::from_utf8_lossy(&got).into_owned(), )) } .await; driving.abort(); answer } /// A header about a connection is malformed in HTTP/3, and the h3 crate does not refuse /// it. Carried to a service reached over HTTP/1.1 it would be read there as a statement /// about *that* connection — which headers to drop, which transfer coding to undo — and a /// filter shown the rendering would not recognise the request the service got. #[tokio::test] async fn a_connection_header_is_refused_over_http3() { let upstream = spawn_h1_echo().await; let addr = spawn_relay_to(upstream, "", Onward::Plain).await; let trailers = ask_with(addr, &[("te", "trailers")]).await; assert!(trailers.is_ok(), "`te: trailers`, the one allowed, was refused: {trailers:?}"); for header in [("transfer-encoding", "chunked"), ("connection", "x-forwarded-for"), ("keep-alive", "timeout=5"), ("upgrade", "websocket"), ("te", "gzip")] { let refused = tokio::time::timeout(Duration::from_secs(5), ask_with(addr, &[header])) .await .expect("a malformed request was left hanging"); assert!(refused.is_err(), "a request carrying {header:?} was carried: {refused:?}"); } } /// What an HTTP/1.1 service answers with says things about its own connection — a body /// sent chunked, a connection offered for reuse — that an HTTP/3 client is required to /// reject a response for. They are the service's, and they stay behind. #[tokio::test] async fn an_http1_answer_reaches_an_http3_client_without_its_connection_headers() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let upstream = listener.local_addr().unwrap(); tokio::spawn(async move { while let Ok((mut socket, _)) = listener.accept().await { tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut buf = [0u8; 4096]; let _ = socket.read(&mut buf).await; let _ = socket .write_all( b"HTTP/1.1 200 OK\r\nconnection: keep-alive, x-internal\r\n\ keep-alive: timeout=5\r\nx-internal: secret\r\n\ content-type: text/plain\r\ntransfer-encoding: chunked\r\n\r\n\ 5\r\nhello\r\n0\r\n\r\n", ) .await; }); } }); let addr = spawn_relay_to(upstream, "", Onward::Plain).await; let (_, headers, body) = ask_with(addr, &[]).await.unwrap(); assert_eq!(body, "hello"); for name in ["connection", "keep-alive", "transfer-encoding", "x-internal"] { assert!(!headers.contains_key(name), "`{name}` reached the HTTP/3 client: {headers:?}"); } assert_eq!(headers.get("content-type").map(|v| v.as_bytes()), Some(&b"text/plain"[..])); } /// A head is bounded, which the h3 crate does not do by default at all. #[tokio::test] async fn a_head_past_the_limit_is_refused_over_http3() { let upstream = spawn_h1_echo().await; let addr = spawn_relay_to(upstream, "", Onward::Plain).await; let big: &'static str = Box::leak("a".repeat(2 * 1024 * 1024).into_boxed_str()); let refused = tokio::time::timeout(Duration::from_secs(10), ask_with(addr, &[("x-big", big)])) .await .expect("an oversized head was left hanging"); // The crate answers `431 Request Header Fields Too Large` itself, and nothing is sent on. assert!( !matches!(refused, Ok((200, _, _))), "a 2 MiB head was carried: {:?}", refused.map(|r| r.2) ); let large: &'static str = Box::leak("a".repeat(256 * 1024).into_boxed_str()); assert!( matches!(ask_with(addr, &[("x-large", large)]).await, Ok((200, _, _))), "a 256 KiB head was refused" ); }