feat(ext): support non-canonical HTTP/1 reason phrases (#2792)

Add a new extension type `hyper::ext::ReasonPhrase` gated by either the `ffi` or `http1` Cargo
features. When enabled, store any non-canonical reason phrases in this extension when parsing
responses, and write this reason phrase instead of the canonical reason phrase when emitting
responses.

Reason phrases are a disused corner of the spec that implementations ought to treat as opaque blobs
of bytes. Unfortunately, real-world traffic sometimes does depend on being able to inspect and
manipulate them.

Non-canonical reason phrases are checked for validity at runtime to prevent invalid and dangerous
characters from being emitted when writing responses. An `unsafe` escape hatch is present for hyper
itself to create reason phrases that have been parsed (and therefore implicitly validated) by
httparse.
This commit is contained in:
Adam C. Foltzer
2022-06-08 15:57:33 -07:00
committed by GitHub
parent f12d4d4aa8
commit b2052a433f
6 changed files with 354 additions and 29 deletions

View File

@@ -2271,6 +2271,62 @@ mod conn {
future::join(server, client).await;
}
#[tokio::test]
async fn get_custom_reason_phrase() {
let _ = ::pretty_env_logger::try_init();
let listener = TkTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
let server = async move {
let mut sock = listener.accept().await.unwrap().0;
let mut buf = [0; 4096];
let n = sock.read(&mut buf).await.expect("read 1");
// Notably:
// - Just a path, since just a path was set
// - No host, since no host was set
let expected = "GET /a HTTP/1.1\r\n\r\n";
assert_eq!(s(&buf[..n]), expected);
sock.write_all(b"HTTP/1.1 200 Alright\r\nContent-Length: 0\r\n\r\n")
.await
.unwrap();
};
let client = async move {
let tcp = tcp_connect(&addr).await.expect("connect");
let (mut client, conn) = conn::handshake(tcp).await.expect("handshake");
tokio::task::spawn(async move {
conn.await.expect("http conn");
});
let req = Request::builder()
.uri("/a")
.body(Default::default())
.unwrap();
let mut res = client.send_request(req).await.expect("send_request");
assert_eq!(res.status(), hyper::StatusCode::OK);
assert_eq!(
res.extensions()
.get::<hyper::ext::ReasonPhrase>()
.expect("custom reason phrase is present")
.as_bytes(),
&b"Alright"[..]
);
assert_eq!(res.headers().len(), 1);
assert_eq!(
res.headers().get(http::header::CONTENT_LENGTH).unwrap(),
"0"
);
assert!(res.body_mut().next().await.is_none());
};
future::join(server, client).await;
}
#[test]
fn incoming_content_length() {
use hyper::body::HttpBody;

View File

@@ -1,6 +1,7 @@
#![deny(warnings)]
#![deny(rust_2018_idioms)]
use std::convert::TryInto;
use std::future::Future;
use std::io::{self, Read, Write};
use std::net::TcpListener as StdTcpListener;
@@ -383,6 +384,33 @@ mod response_body_lengths {
}
}
#[test]
fn get_response_custom_reason_phrase() {
let _ = pretty_env_logger::try_init();
let server = serve();
server.reply().reason_phrase("Cool");
let mut req = connect(server.addr());
req.write_all(
b"\
GET / HTTP/1.1\r\n\
Host: example.domain\r\n\
Connection: close\r\n\
\r\n\
",
)
.unwrap();
let mut response = String::new();
req.read_to_string(&mut response).unwrap();
let mut lines = response.lines();
assert_eq!(lines.next(), Some("HTTP/1.1 200 Cool"));
let mut lines = lines.skip_while(|line| !line.is_empty());
assert_eq!(lines.next(), Some(""));
assert_eq!(lines.next(), None);
}
#[test]
fn get_chunked_response_with_ka() {
let foo_bar = b"foo bar baz";
@@ -2671,6 +2699,17 @@ impl<'a> ReplyBuilder<'a> {
self
}
fn reason_phrase(self, reason: &str) -> Self {
self.tx
.lock()
.unwrap()
.send(Reply::ReasonPhrase(
reason.as_bytes().try_into().expect("reason phrase"),
))
.unwrap();
self
}
fn version(self, version: hyper::Version) -> Self {
self.tx
.lock()
@@ -2744,6 +2783,7 @@ struct TestService {
#[derive(Debug)]
enum Reply {
Status(hyper::StatusCode),
ReasonPhrase(hyper::ext::ReasonPhrase),
Version(hyper::Version),
Header(HeaderName, HeaderValue),
Body(hyper::Body),
@@ -2799,6 +2839,9 @@ impl TestService {
Reply::Status(s) => {
*res.status_mut() = s;
}
Reply::ReasonPhrase(reason) => {
res.extensions_mut().insert(reason);
}
Reply::Version(v) => {
*res.version_mut() = v;
}