Skip to content

Commit b2052a4

Browse files
authored
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.
1 parent f12d4d4 commit b2052a4

File tree

6 files changed

+354
-29
lines changed

6 files changed

+354
-29
lines changed

src/ext.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ use std::collections::HashMap;
1111
#[cfg(feature = "http2")]
1212
use std::fmt;
1313

14+
#[cfg(any(feature = "http1", feature = "ffi"))]
15+
mod h1_reason_phrase;
16+
#[cfg(any(feature = "http1", feature = "ffi"))]
17+
pub use h1_reason_phrase::ReasonPhrase;
18+
1419
#[cfg(feature = "http2")]
1520
/// Represents the `:protocol` pseudo-header used by
1621
/// the [Extended CONNECT Protocol].

src/ext/h1_reason_phrase.rs

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
use std::convert::TryFrom;
2+
3+
use bytes::Bytes;
4+
5+
/// A reason phrase in an HTTP/1 response.
6+
///
7+
/// # Clients
8+
///
9+
/// For clients, a `ReasonPhrase` will be present in the extensions of the `http::Response` returned
10+
/// for a request if the reason phrase is different from the canonical reason phrase for the
11+
/// response's status code. For example, if a server returns `HTTP/1.1 200 Awesome`, the
12+
/// `ReasonPhrase` will be present and contain `Awesome`, but if a server returns `HTTP/1.1 200 OK`,
13+
/// the response will not contain a `ReasonPhrase`.
14+
///
15+
/// ```no_run
16+
/// # #[cfg(all(feature = "tcp", feature = "client", feature = "http1"))]
17+
/// # async fn fake_fetch() -> hyper::Result<()> {
18+
/// use hyper::{Client, Uri};
19+
/// use hyper::ext::ReasonPhrase;
20+
///
21+
/// let res = Client::new().get(Uri::from_static("http://example.com/non_canonical_reason")).await?;
22+
///
23+
/// // Print out the non-canonical reason phrase, if it has one...
24+
/// if let Some(reason) = res.extensions().get::<ReasonPhrase>() {
25+
/// println!("non-canonical reason: {}", std::str::from_utf8(reason.as_bytes()).unwrap());
26+
/// }
27+
/// # Ok(())
28+
/// # }
29+
/// ```
30+
///
31+
/// # Servers
32+
///
33+
/// When a `ReasonPhrase` is present in the extensions of the `http::Response` written by a server,
34+
/// its contents will be written in place of the canonical reason phrase when responding via HTTP/1.
35+
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
36+
pub struct ReasonPhrase(Bytes);
37+
38+
impl ReasonPhrase {
39+
/// Gets the reason phrase as bytes.
40+
pub fn as_bytes(&self) -> &[u8] {
41+
&self.0
42+
}
43+
44+
/// Converts a static byte slice to a reason phrase.
45+
pub fn from_static(reason: &'static [u8]) -> Self {
46+
// TODO: this can be made const once MSRV is >= 1.57.0
47+
if find_invalid_byte(reason).is_some() {
48+
panic!("invalid byte in static reason phrase");
49+
}
50+
Self(Bytes::from_static(reason))
51+
}
52+
53+
/// Converts a `Bytes` directly into a `ReasonPhrase` without validating.
54+
///
55+
/// Use with care; invalid bytes in a reason phrase can cause serious security problems if
56+
/// emitted in a response.
57+
pub unsafe fn from_bytes_unchecked(reason: Bytes) -> Self {
58+
Self(reason)
59+
}
60+
}
61+
62+
impl TryFrom<&[u8]> for ReasonPhrase {
63+
type Error = InvalidReasonPhrase;
64+
65+
fn try_from(reason: &[u8]) -> Result<Self, Self::Error> {
66+
if let Some(bad_byte) = find_invalid_byte(reason) {
67+
Err(InvalidReasonPhrase { bad_byte })
68+
} else {
69+
Ok(Self(Bytes::copy_from_slice(reason)))
70+
}
71+
}
72+
}
73+
74+
impl TryFrom<Vec<u8>> for ReasonPhrase {
75+
type Error = InvalidReasonPhrase;
76+
77+
fn try_from(reason: Vec<u8>) -> Result<Self, Self::Error> {
78+
if let Some(bad_byte) = find_invalid_byte(&reason) {
79+
Err(InvalidReasonPhrase { bad_byte })
80+
} else {
81+
Ok(Self(Bytes::from(reason)))
82+
}
83+
}
84+
}
85+
86+
impl TryFrom<String> for ReasonPhrase {
87+
type Error = InvalidReasonPhrase;
88+
89+
fn try_from(reason: String) -> Result<Self, Self::Error> {
90+
if let Some(bad_byte) = find_invalid_byte(reason.as_bytes()) {
91+
Err(InvalidReasonPhrase { bad_byte })
92+
} else {
93+
Ok(Self(Bytes::from(reason)))
94+
}
95+
}
96+
}
97+
98+
impl TryFrom<Bytes> for ReasonPhrase {
99+
type Error = InvalidReasonPhrase;
100+
101+
fn try_from(reason: Bytes) -> Result<Self, Self::Error> {
102+
if let Some(bad_byte) = find_invalid_byte(&reason) {
103+
Err(InvalidReasonPhrase { bad_byte })
104+
} else {
105+
Ok(Self(reason))
106+
}
107+
}
108+
}
109+
110+
impl Into<Bytes> for ReasonPhrase {
111+
fn into(self) -> Bytes {
112+
self.0
113+
}
114+
}
115+
116+
impl AsRef<[u8]> for ReasonPhrase {
117+
fn as_ref(&self) -> &[u8] {
118+
&self.0
119+
}
120+
}
121+
122+
/// Error indicating an invalid byte when constructing a `ReasonPhrase`.
123+
///
124+
/// See [the spec][spec] for details on allowed bytes.
125+
///
126+
/// [spec]: https://httpwg.org/http-core/draft-ietf-httpbis-messaging-latest.html#rfc.section.4.p.7
127+
#[derive(Debug)]
128+
pub struct InvalidReasonPhrase {
129+
bad_byte: u8,
130+
}
131+
132+
impl std::fmt::Display for InvalidReasonPhrase {
133+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134+
write!(f, "Invalid byte in reason phrase: {}", self.bad_byte)
135+
}
136+
}
137+
138+
impl std::error::Error for InvalidReasonPhrase {}
139+
140+
const fn is_valid_byte(b: u8) -> bool {
141+
// See https://www.rfc-editor.org/rfc/rfc5234.html#appendix-B.1
142+
const fn is_vchar(b: u8) -> bool {
143+
0x21 <= b && b <= 0x7E
144+
}
145+
146+
// See https://httpwg.org/http-core/draft-ietf-httpbis-semantics-latest.html#fields.values
147+
//
148+
// The 0xFF comparison is technically redundant, but it matches the text of the spec more
149+
// clearly and will be optimized away.
150+
#[allow(unused_comparisons)]
151+
const fn is_obs_text(b: u8) -> bool {
152+
0x80 <= b && b <= 0xFF
153+
}
154+
155+
// See https://httpwg.org/http-core/draft-ietf-httpbis-messaging-latest.html#rfc.section.4.p.7
156+
b == b'\t' || b == b' ' || is_vchar(b) || is_obs_text(b)
157+
}
158+
159+
const fn find_invalid_byte(bytes: &[u8]) -> Option<u8> {
160+
let mut i = 0;
161+
while i < bytes.len() {
162+
let b = bytes[i];
163+
if !is_valid_byte(b) {
164+
return Some(b);
165+
}
166+
i += 1;
167+
}
168+
None
169+
}
170+
171+
#[cfg(test)]
172+
mod tests {
173+
use super::*;
174+
175+
#[test]
176+
fn basic_valid() {
177+
const PHRASE: &'static [u8] = b"OK";
178+
assert_eq!(ReasonPhrase::from_static(PHRASE).as_bytes(), PHRASE);
179+
assert_eq!(ReasonPhrase::try_from(PHRASE).unwrap().as_bytes(), PHRASE);
180+
}
181+
182+
#[test]
183+
fn empty_valid() {
184+
const PHRASE: &'static [u8] = b"";
185+
assert_eq!(ReasonPhrase::from_static(PHRASE).as_bytes(), PHRASE);
186+
assert_eq!(ReasonPhrase::try_from(PHRASE).unwrap().as_bytes(), PHRASE);
187+
}
188+
189+
#[test]
190+
fn obs_text_valid() {
191+
const PHRASE: &'static [u8] = b"hyp\xe9r";
192+
assert_eq!(ReasonPhrase::from_static(PHRASE).as_bytes(), PHRASE);
193+
assert_eq!(ReasonPhrase::try_from(PHRASE).unwrap().as_bytes(), PHRASE);
194+
}
195+
196+
const NEWLINE_PHRASE: &'static [u8] = b"hyp\ner";
197+
198+
#[test]
199+
#[should_panic]
200+
fn newline_invalid_panic() {
201+
ReasonPhrase::from_static(NEWLINE_PHRASE);
202+
}
203+
204+
#[test]
205+
fn newline_invalid_err() {
206+
assert!(ReasonPhrase::try_from(NEWLINE_PHRASE).is_err());
207+
}
208+
209+
const CR_PHRASE: &'static [u8] = b"hyp\rer";
210+
211+
#[test]
212+
#[should_panic]
213+
fn cr_invalid_panic() {
214+
ReasonPhrase::from_static(CR_PHRASE);
215+
}
216+
217+
#[test]
218+
fn cr_invalid_err() {
219+
assert!(ReasonPhrase::try_from(CR_PHRASE).is_err());
220+
}
221+
}

src/ffi/http_types.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use super::body::{hyper_body, hyper_buf};
66
use super::error::hyper_code;
77
use super::task::{hyper_task_return_type, AsTaskType};
88
use super::{UserDataPointer, HYPER_ITER_CONTINUE};
9-
use crate::ext::{HeaderCaseMap, OriginalHeaderOrder};
9+
use crate::ext::{HeaderCaseMap, OriginalHeaderOrder, ReasonPhrase};
1010
use crate::header::{HeaderName, HeaderValue};
1111
use crate::{Body, HeaderMap, Method, Request, Response, Uri};
1212

@@ -25,9 +25,6 @@ pub struct hyper_headers {
2525
orig_order: OriginalHeaderOrder,
2626
}
2727

28-
#[derive(Debug)]
29-
pub(crate) struct ReasonPhrase(pub(crate) Bytes);
30-
3128
pub(crate) struct RawHeaders(pub(crate) hyper_buf);
3229

3330
pub(crate) struct OnInformational {
@@ -365,7 +362,7 @@ impl hyper_response {
365362

366363
fn reason_phrase(&self) -> &[u8] {
367364
if let Some(reason) = self.0.extensions().get::<ReasonPhrase>() {
368-
return &reason.0;
365+
return reason.as_bytes();
369366
}
370367

371368
if let Some(reason) = self.0.status().canonical_reason() {

src/proto/h1/role.rs

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::fmt::{self, Write};
22
use std::mem::MaybeUninit;
33

4-
#[cfg(any(test, feature = "server", feature = "ffi"))]
54
use bytes::Bytes;
65
use bytes::BytesMut;
76
#[cfg(feature = "server")]
@@ -377,7 +376,13 @@ impl Http1Transaction for Server {
377376

378377
let init_cap = 30 + msg.head.headers.len() * AVERAGE_HEADER_SIZE;
379378
dst.reserve(init_cap);
380-
if msg.head.version == Version::HTTP_11 && msg.head.subject == StatusCode::OK {
379+
380+
let custom_reason_phrase = msg.head.extensions.get::<crate::ext::ReasonPhrase>();
381+
382+
if msg.head.version == Version::HTTP_11
383+
&& msg.head.subject == StatusCode::OK
384+
&& custom_reason_phrase.is_none()
385+
{
381386
extend(dst, b"HTTP/1.1 200 OK\r\n");
382387
} else {
383388
match msg.head.version {
@@ -392,15 +397,21 @@ impl Http1Transaction for Server {
392397

393398
extend(dst, msg.head.subject.as_str().as_bytes());
394399
extend(dst, b" ");
395-
// a reason MUST be written, as many parsers will expect it.
396-
extend(
397-
dst,
398-
msg.head
399-
.subject
400-
.canonical_reason()
401-
.unwrap_or("<none>")
402-
.as_bytes(),
403-
);
400+
401+
if let Some(reason) = custom_reason_phrase {
402+
extend(dst, reason.as_bytes());
403+
} else {
404+
// a reason MUST be written, as many parsers will expect it.
405+
extend(
406+
dst,
407+
msg.head
408+
.subject
409+
.canonical_reason()
410+
.unwrap_or("<none>")
411+
.as_bytes(),
412+
);
413+
}
414+
404415
extend(dst, b"\r\n");
405416
}
406417

@@ -944,9 +955,6 @@ impl Http1Transaction for Client {
944955
trace!("Response.parse Complete({})", len);
945956
let status = StatusCode::from_u16(res.code.unwrap())?;
946957

947-
#[cfg(not(feature = "ffi"))]
948-
let reason = ();
949-
#[cfg(feature = "ffi")]
950958
let reason = {
951959
let reason = res.reason.unwrap();
952960
// Only save the reason phrase if it isn't the canonical reason
@@ -970,12 +978,7 @@ impl Http1Transaction for Client {
970978
Err(httparse::Error::Version) if ctx.h09_responses => {
971979
trace!("Response.parse accepted HTTP/0.9 response");
972980

973-
#[cfg(not(feature = "ffi"))]
974-
let reason = ();
975-
#[cfg(feature = "ffi")]
976-
let reason = None;
977-
978-
(0, StatusCode::OK, reason, Version::HTTP_09, 0)
981+
(0, StatusCode::OK, None, Version::HTTP_09, 0)
979982
}
980983
Err(e) => return Err(e.into()),
981984
}
@@ -1058,12 +1061,12 @@ impl Http1Transaction for Client {
10581061
extensions.insert(header_order);
10591062
}
10601063

1061-
#[cfg(feature = "ffi")]
10621064
if let Some(reason) = reason {
1063-
extensions.insert(crate::ffi::ReasonPhrase(reason));
1065+
// Safety: httparse ensures that only valid reason phrase bytes are present in this
1066+
// field.
1067+
let reason = unsafe { crate::ext::ReasonPhrase::from_bytes_unchecked(reason) };
1068+
extensions.insert(reason);
10641069
}
1065-
#[cfg(not(feature = "ffi"))]
1066-
drop(reason);
10671070

10681071
#[cfg(feature = "ffi")]
10691072
if ctx.raw_headers {

0 commit comments

Comments
 (0)