diff --git a/litebox_egress_proxy/Cargo.toml b/litebox_egress_proxy/Cargo.toml index 2defd9c33..fc5d1fa23 100644 --- a/litebox_egress_proxy/Cargo.toml +++ b/litebox_egress_proxy/Cargo.toml @@ -14,6 +14,7 @@ clap = { version = "4.5", default-features = false, features = [ hashbrown = "0.15.2" http-body-util = { version = "0.1.3", default-features = false } hyper = { version = "1.8", default-features = false, features = [ + "client", "http1", "server", ] } diff --git a/litebox_egress_proxy/src/config.rs b/litebox_egress_proxy/src/config.rs index 5863c3d99..739a75bbc 100644 --- a/litebox_egress_proxy/src/config.rs +++ b/litebox_egress_proxy/src/config.rs @@ -14,7 +14,7 @@ use crate::policy::{HostPolicy, PolicyError}; #[derive(Debug, Parser)] #[command( name = "litebox_egress_proxy", - about = "Hostname-filtering CONNECT egress proxy" + about = "Hostname-filtering HTTP/HTTPS egress proxy" )] pub struct Cli { /// Loopback address to bind, for example `127.0.0.1:0`. diff --git a/litebox_egress_proxy/src/lib.rs b/litebox_egress_proxy/src/lib.rs index 657730c14..0d3903748 100644 --- a/litebox_egress_proxy/src/lib.rs +++ b/litebox_egress_proxy/src/lib.rs @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! A standalone, hostname-filtering CONNECT egress proxy for LiteBox. +//! A standalone, hostname-filtering HTTP and HTTPS egress proxy for LiteBox. //! //! Authorized hostnames are resolved on demand through the trusted host -//! resolver, then connected using the returned numeric addresses. CONNECT -//! tunnels relay bytes without inspecting or terminating TLS. +//! resolver, then connected using the returned numeric addresses. Plain HTTP +//! requests are forwarded without buffering their bodies; HTTPS connections +//! use CONNECT tunnels that relay bytes without inspecting or terminating TLS. extern crate alloc; diff --git a/litebox_egress_proxy/src/proxy.rs b/litebox_egress_proxy/src/proxy.rs index 17f961a19..b40698140 100644 --- a/litebox_egress_proxy/src/proxy.rs +++ b/litebox_egress_proxy/src/proxy.rs @@ -1,42 +1,56 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Connection acceptance, authorization, and CONNECT tunnelling. +//! Connection acceptance, authorization, HTTP forwarding, and CONNECT tunnelling. //! -//! Every CONNECT request is authorized before DNS or upstream activity. A -//! successful request consumes its client connection by upgrading it to one -//! bounded bidirectional tunnel. +//! Every request is authorized before DNS or upstream activity. Plain HTTP +//! requests use a dedicated upstream connection; CONNECT consumes its client +//! connection by upgrading it to a bounded tunnel. use core::convert::Infallible; +use core::error::Error as StdError; use core::time::Duration; use std::io; use std::sync::Arc; -use http_body_util::Empty; -use hyper::body::{Bytes, Incoming}; -use hyper::header::{self, HeaderValue}; +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Empty}; +use hyper::body::{Body, Bytes, Incoming}; +use hyper::client::conn::http1 as client_http1; +use hyper::header::{self, HeaderName, HeaderValue}; use hyper::http::uri::Authority; use hyper::server::conn::http1 as server_http1; use hyper::service::service_fn; -use hyper::{Method, Request, Response, StatusCode, Uri}; +use hyper::{HeaderMap, Method, Request, Response, StatusCode, Uri, Version}; use hyper_util::rt::{TokioIo, TokioTimer}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::time::timeout; -use crate::authority::{RequestAuthority, parse_authority}; +use crate::authority::{DEFAULT_HTTP_PORT, RequestAuthority, parse_authority}; use crate::connector::{BoxedUpstreamStream, UPSTREAM_CONNECT_TIMEOUT, UpstreamConnector}; use crate::idle_timeout::IdleTimeoutStream; use crate::policy::HostPolicy; const MAX_CONCURRENT_CLIENT_CONNECTIONS: usize = 256; -const MAX_REQUEST_HEADER_BYTES: usize = 16 * 1024; +const MAX_HEADER_BYTES: usize = 16 * 1024; const MAX_HEADER_FIELDS: usize = 100; const IDLE_TIMEOUT: Duration = Duration::from_secs(60); const REQUEST_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(30); - -/// Response body type produced by the proxy. -type ProxyBody = Empty; +const HOP_BY_HOP_HEADERS: [&str; 9] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +type BoxError = Box; +type ProxyBody = BoxBody; /// Immutable state shared by every connection. pub struct ProxyState { @@ -80,7 +94,7 @@ pub async fn serve(listener: TcpListener, state: Arc) -> io::Result< } } -/// Serves one CONNECT tunnel or rejection on a client connection. +/// Serves one HTTP request or CONNECT tunnel on a client connection. async fn serve_connection(state: Arc, stream: TcpStream, permit: OwnedSemaphorePermit) { if stream.set_nodelay(true).is_err() { return; @@ -98,7 +112,7 @@ async fn serve_connection(state: Arc, stream: TcpStream, permit: Own builder .timer(TokioTimer::new()) .header_read_timeout(Some(REQUEST_HEADER_READ_TIMEOUT)) - .max_buf_size(MAX_REQUEST_HEADER_BYTES) + .max_buf_size(MAX_HEADER_BYTES) .max_headers(MAX_HEADER_FIELDS); if let Err(error) = builder.serve_connection(io, service).with_upgrades().await { @@ -106,19 +120,170 @@ async fn serve_connection(state: Arc, stream: TcpStream, permit: Own } } -/// Dispatches one request. async fn handle_request( state: Arc, permit: Arc, request: Request, ) -> Response { - if request.method() != Method::CONNECT { + if request.method() == Method::CONNECT { + handle_connect(&state, permit, request).await + } else { + let mut response = handle_forward(&state, request).await; + response + .headers_mut() + .insert(header::CONNECTION, HeaderValue::from_static("close")); + response + } +} + +async fn handle_forward(state: &ProxyState, request: Request) -> Response { + if request.headers().contains_key(header::UPGRADE) { + return status_response(StatusCode::NOT_IMPLEMENTED); + } + if request.headers().get_all(header::HOST).iter().count() > 1 { + return status_response(StatusCode::BAD_REQUEST); + } + + let Some(authority) = forward_authority(request.uri()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + let Some(origin_target) = origin_form_target(request.uri()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + if !host_header_is_consistent(&request, &authority, Some(DEFAULT_HTTP_PORT)) { + return status_response(StatusCode::BAD_REQUEST); + } + let Some(is_chunked) = chunked_transfer_encoding(request.headers()) else { return status_response(StatusCode::NOT_IMPLEMENTED); + }; + let had_content_length = request.headers().contains_key(header::CONTENT_LENGTH); + + let (mut parts, body) = request.into_parts(); + parts.uri = origin_target; + parts.version = Version::HTTP_11; + if !strip_hop_by_hop_headers(&mut parts.headers) { + return status_response(StatusCode::BAD_REQUEST); + } + parts.headers.remove(header::CONTENT_LENGTH); + if is_chunked { + parts.headers.insert( + header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + } else if had_content_length { + let Some(length) = body.size_hint().exact() else { + return status_response(StatusCode::BAD_REQUEST); + }; + let Ok(length) = HeaderValue::from_str(&length.to_string()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + parts.headers.insert(header::CONTENT_LENGTH, length); + } + parts.headers.remove(header::HOST); + let Ok(host) = HeaderValue::from_str(&authority.host_header_value()) else { + return status_response(StatusCode::BAD_REQUEST); + }; + parts.headers.insert(header::HOST, host); + + if !state.policy.allows(authority.host(), authority.port()) { + return status_response(StatusCode::FORBIDDEN); } - handle_connect(&state, permit, request).await + + let upstream = match connect_upstream(state, &authority).await { + Ok(stream) => IdleTimeoutStream::new(stream, IDLE_TIMEOUT), + Err(status) => return status_response(status), + }; + + forward_to_upstream(Request::from_parts(parts, body), upstream).await +} + +async fn forward_to_upstream( + request: Request, + upstream: IdleTimeoutStream, +) -> Response { + let handshake = client_http1::Builder::new() + .max_buf_size(MAX_HEADER_BYTES) + .max_headers(MAX_HEADER_FIELDS) + .handshake(TokioIo::new(upstream)) + .await; + let (mut sender, connection) = match handshake { + Ok(pair) => pair, + Err(error) => { + diagnostic(format_args!("upstream handshake failed: {error}")); + return status_response(StatusCode::BAD_GATEWAY); + } + }; + + tokio::spawn(async move { + if let Err(error) = connection.await { + diagnostic(format_args!("upstream connection ended: {error}")); + } + }); + + let upstream_response = match sender.send_request(request).await { + Ok(response) => response, + Err(error) => { + diagnostic(format_args!("upstream request failed: {error}")); + return status_response(StatusCode::BAD_GATEWAY); + } + }; + if upstream_response.status() == StatusCode::SWITCHING_PROTOCOLS { + return status_response(StatusCode::BAD_GATEWAY); + } + let (mut parts, body) = upstream_response.into_parts(); + let Some(is_chunked) = chunked_transfer_encoding(&parts.headers) else { + return status_response(StatusCode::BAD_GATEWAY); + }; + if !strip_hop_by_hop_headers(&mut parts.headers) { + return status_response(StatusCode::BAD_GATEWAY); + } + if is_chunked { + parts.headers.remove(header::CONTENT_LENGTH); + } + Response::from_parts(parts, body.map_err(BoxError::from).boxed()) +} + +fn chunked_transfer_encoding(headers: &HeaderMap) -> Option { + let mut found = false; + for value in headers.get_all(header::TRANSFER_ENCODING) { + let text = value.to_str().ok()?; + for coding in text.split(',') { + if found || !coding.trim().eq_ignore_ascii_case("chunked") { + return None; + } + found = true; + } + } + Some(found) +} + +fn strip_hop_by_hop_headers(headers: &mut HeaderMap) -> bool { + let mut connection_named = Vec::new(); + for value in headers.get_all(header::CONNECTION) { + let Ok(text) = value.to_str() else { + return false; + }; + for token in text.split(',') { + let token = token.trim(); + if token.is_empty() { + return false; + } + let Ok(name) = HeaderName::from_bytes(token.as_bytes()) else { + return false; + }; + connection_named.push(name); + } + } + + for name in connection_named { + headers.remove(name); + } + for name in HOP_BY_HOP_HEADERS { + headers.remove(name); + } + true } -/// Handles one CONNECT tunnel request. async fn handle_connect( state: &ProxyState, permit: Arc, @@ -138,7 +303,7 @@ async fn handle_connect( return status_response(StatusCode::BAD_REQUEST); }; - if !host_header_is_consistent(&request, &authority) { + if !host_header_is_consistent(&request, &authority, None) { return status_response(StatusCode::BAD_REQUEST); } @@ -167,12 +332,29 @@ async fn handle_connect( } }); - let mut response = Response::new(Empty::new()); + let mut response = Response::new(empty_body()); *response.status_mut() = StatusCode::OK; response } -/// Canonicalizes the authority-form target of a CONNECT request. +fn forward_authority(uri: &Uri) -> Option { + if !uri.scheme_str()?.eq_ignore_ascii_case("http") { + return None; + } + let raw = uri.authority().map(Authority::as_str)?; + parse_authority(raw, Some(DEFAULT_HTTP_PORT)).ok() +} + +fn origin_form_target(uri: &Uri) -> Option { + let target = uri + .path_and_query() + .map_or("/", hyper::http::uri::PathAndQuery::as_str); + match target.strip_prefix('?') { + Some(query) => format!("/?{query}").parse().ok(), + None => target.parse().ok(), + } +} + fn connect_authority(uri: &Uri) -> Option { if uri.scheme_str().is_some() || !uri.path().is_empty() || uri.query().is_some() { return None; @@ -181,19 +363,21 @@ fn connect_authority(uri: &Uri) -> Option { parse_authority(raw, None).ok() } -/// Returns whether a CONNECT Host header matches the request target. -fn host_header_is_consistent(request: &Request, authority: &RequestAuthority) -> bool { +fn host_header_is_consistent( + request: &Request, + authority: &RequestAuthority, + default_port: Option, +) -> bool { let Some(value) = request.headers().get(header::HOST) else { return true; }; value .to_str() .ok() - .and_then(|raw| parse_authority(raw, None).ok()) + .and_then(|raw| parse_authority(raw, default_port).ok()) .is_some_and(|host_header| &host_header == authority) } -/// Resolves and connects to an authorized hostname. async fn connect_upstream( state: &ProxyState, authority: &RequestAuthority, @@ -215,8 +399,14 @@ async fn connect_upstream( } } +fn empty_body() -> ProxyBody { + Empty::::new() + .map_err(|never| match never {}) + .boxed() +} + fn status_response(status: StatusCode) -> Response { - let mut response = Response::new(Empty::new()); + let mut response = Response::new(empty_body()); *response.status_mut() = status; response .headers_mut() @@ -246,4 +436,22 @@ mod tests { assert!(connect_authority(&uri("example.com")).is_none()); assert!(connect_authority(&uri("example.com:0")).is_none()); } + + #[test] + fn accepts_only_a_single_chunked_transfer_coding() { + let mut headers = HeaderMap::new(); + assert_eq!(chunked_transfer_encoding(&headers), Some(false)); + + headers.insert( + header::TRANSFER_ENCODING, + HeaderValue::from_static("Chunked"), + ); + assert_eq!(chunked_transfer_encoding(&headers), Some(true)); + + headers.insert( + header::TRANSFER_ENCODING, + HeaderValue::from_static("gzip, chunked"), + ); + assert_eq!(chunked_transfer_encoding(&headers), None); + } } diff --git a/litebox_egress_proxy/tests/loopback.rs b/litebox_egress_proxy/tests/loopback.rs index 277c14aeb..367faed6b 100644 --- a/litebox_egress_proxy/tests/loopback.rs +++ b/litebox_egress_proxy/tests/loopback.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -//! Hermetic loopback tests for the CONNECT proxy. +//! Hermetic loopback tests for HTTP forwarding and CONNECT tunneling. use std::collections::HashMap; use std::io; @@ -15,6 +15,7 @@ use litebox_egress_proxy::policy::{HostPolicy, Hostname}; use litebox_egress_proxy::proxy::{ProxyState, serve}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; use tokio::time::timeout; const TEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -123,6 +124,14 @@ impl ProxyClient { } async fn read_response(&mut self) -> u16 { + let head = self.read_response_head().await; + head.split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) + .expect("status code") + } + + async fn read_response_head(&mut self) -> String { let head_end = loop { if let Some(index) = find_subslice(&self.buffer, b"\r\n\r\n") { break index + 4; @@ -135,17 +144,14 @@ impl ProxyClient { let head = String::from_utf8(self.buffer[..head_end].to_vec()).expect("ASCII head"); self.buffer.drain(..head_end); - head.split_whitespace() - .nth(1) - .and_then(|code| code.parse::().ok()) - .expect("status code") + head } async fn read_exact(&mut self, length: usize) -> Vec { while self.buffer.len() < length { assert!( self.fill().await, - "connection closed before the tunnel data" + "connection closed before the expected data" ); } self.buffer.drain(..length).collect() @@ -158,6 +164,46 @@ fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { .position(|window| window == needle) } +fn header_value<'a>(head: &'a str, name: &str) -> Option<&'a str> { + head.lines().skip(1).find_map(|line| { + let (key, value) = line.split_once(':')?; + key.trim().eq_ignore_ascii_case(name).then(|| value.trim()) + }) +} + +async fn recording_upstream( + request_end: &'static [u8], + response: &'static [u8], +) -> (SocketAddr, oneshot::Receiver) { + let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) + .await + .expect("upstream listener"); + let address = listener.local_addr().expect("upstream address"); + let (sender, receiver) = oneshot::channel(); + + tokio::spawn(async move { + let Ok((mut stream, _peer)) = listener.accept().await else { + return; + }; + let mut request = Vec::new(); + let mut chunk = [0_u8; 4096]; + while find_subslice(&request, request_end).is_none() { + let Ok(read) = stream.read(&mut chunk).await else { + return; + }; + if read == 0 { + return; + } + request.extend_from_slice(&chunk[..read]); + } + + let _ = sender.send(String::from_utf8_lossy(&request).into_owned()); + let _ = stream.write_all(response).await; + }); + + (address, receiver) +} + async fn echo_upstream() -> SocketAddr { let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) .await @@ -176,6 +222,172 @@ async fn echo_upstream() -> SocketAddr { address } +#[tokio::test] +async fn http_request_is_rewritten_and_relayed() { + let (upstream, requests) = recording_upstream( + b"\r\n\r\nhello", + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nhi", + ) + .await; + + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let mut client = proxy.connect().await; + client + .send( + concat!( + "POST http://Allowed.Example?query=1 HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Content-Length: 5\r\n", + "Proxy-Connection: keep-alive\r\n", + "Connection: X-Secret\r\n", + "X-Secret: value\r\n", + "X-Kept: value\r\n", + "\r\n", + "hello" + ) + .as_bytes(), + ) + .await; + + assert_eq!(client.read_response().await, 200); + assert_eq!(client.read_exact(2).await, b"hi"); + assert!(!client.fill().await); + + let forwarded = timeout(TEST_TIMEOUT, requests) + .await + .expect("upstream request did not time out") + .expect("upstream received the request"); + assert!(forwarded.starts_with("POST /?query=1 HTTP/1.1\r\n")); + assert_eq!(header_value(&forwarded, "host"), Some("allowed.example")); + assert_eq!(header_value(&forwarded, "content-length"), Some("5")); + assert!(header_value(&forwarded, "proxy-connection").is_none()); + assert!(header_value(&forwarded, "connection").is_none()); + assert!(header_value(&forwarded, "x-secret").is_none()); + assert_eq!(header_value(&forwarded, "x-kept"), Some("value")); + assert!(forwarded.ends_with("hello")); +} + +#[tokio::test] +async fn chunked_get_body_is_relayed() { + let (upstream, requests) = recording_upstream( + b"\r\n0\r\n\r\n", + b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", + ) + .await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let mut client = proxy.connect().await; + client + .send( + concat!( + "GET http://allowed.example/search HTTP/1.1\r\n", + "Host: allowed.example\r\n", + "Transfer-Encoding: chunked\r\n", + "\r\n", + "5\r\nhello\r\n0\r\n\r\n" + ) + .as_bytes(), + ) + .await; + + assert_eq!(client.read_response().await, 204); + let forwarded = timeout(TEST_TIMEOUT, requests) + .await + .expect("upstream request did not time out") + .expect("upstream received the request"); + assert!(forwarded.starts_with("GET /search HTTP/1.1\r\n")); + assert_eq!( + header_value(&forwarded, "transfer-encoding"), + Some("chunked") + ); + assert!(header_value(&forwarded, "content-length").is_none()); + assert!(forwarded.ends_with("5\r\nhello\r\n0\r\n\r\n")); +} + +#[tokio::test] +async fn empty_post_preserves_content_length() { + let (upstream, requests) = recording_upstream( + b"\r\n\r\n", + b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", + ) + .await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + assert_eq!( + proxy + .request( + "POST http://allowed.example/upload HTTP/1.1\r\nHost: allowed.example\r\nContent-Length: 0\r\n\r\n", + ) + .await, + 204 + ); + let forwarded = timeout(TEST_TIMEOUT, requests) + .await + .expect("upstream request did not time out") + .expect("upstream received the request"); + assert_eq!(header_value(&forwarded, "content-length"), Some("0")); + assert!(header_value(&forwarded, "transfer-encoding").is_none()); +} + +#[tokio::test] +async fn head_response_preserves_content_length() { + let (upstream, _requests) = recording_upstream( + b"\r\n\r\n", + b"HTTP/1.1 200 OK\r\nContent-Length: 123\r\nConnection: close\r\n\r\n", + ) + .await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + let mut client = proxy.connect().await; + client + .send(b"HEAD http://allowed.example/file HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await; + + let response = client.read_response_head().await; + assert!(response.starts_with("HTTP/1.1 200 ")); + assert_eq!(header_value(&response, "content-length"), Some("123")); + assert!(!client.fill().await); +} + +#[tokio::test] +async fn invalid_upstream_responses_yield_bad_gateway() { + for response in [ + &b"HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n"[..], + &b"HTTP/1.1 101 Switching Protocols\r\nConnection: upgrade\r\nUpgrade: websocket\r\n\r\n"[..], + ] { + let (upstream, _requests) = recording_upstream(b"\r\n\r\n", response).await; + let proxy = TestProxy::start( + &["allowed.example:80"], + &[("allowed.example", 80, upstream)], + ) + .await; + + assert_eq!( + proxy + .request("GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n") + .await, + 502 + ); + } +} + #[tokio::test] async fn connect_tunnel_relays_bytes() { let upstream = echo_upstream().await; @@ -209,7 +421,7 @@ async fn connect_tunnel_relays_bytes() { async fn firewall_rejects_without_network_activity() { let upstream = echo_upstream().await; let proxy = TestProxy::start( - &["allowed.example:443"], + &["allowed.example:80", "allowed.example:443"], &[("allowed.example", 443, upstream)], ) .await; @@ -248,7 +460,43 @@ async fn firewall_rejects_without_network_activity() { 400, ), ( - "GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n", + "GET http://denied.example/ HTTP/1.1\r\nHost: denied.example\r\n\r\n", + 403, + ), + ( + "GET http://allowed.example:8080/ HTTP/1.1\r\nHost: allowed.example:8080\r\n\r\n", + 403, + ), + ( + "GET https://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\n\r\n", + 400, + ), + ( + "GET /relative HTTP/1.1\r\nHost: allowed.example\r\n\r\n", + 400, + ), + ( + "GET http://93.184.216.1/ HTTP/1.1\r\nHost: 93.184.216.1\r\n\r\n", + 400, + ), + ( + "GET http://allowed.example/ HTTP/1.1\r\nHost: other.example\r\n\r\n", + 400, + ), + ( + "GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\nHost: allowed.example\r\n\r\n", + 400, + ), + ( + "GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\nUpgrade: websocket\r\n\r\n", + 501, + ), + ( + "GET http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\nConnection: bad token\r\n\r\n", + 400, + ), + ( + "POST http://allowed.example/ HTTP/1.1\r\nHost: allowed.example\r\nTransfer-Encoding: gzip, chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", 501, ), ] {