Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#769: Fix request pseudo-header construction for CONNECT & OPTION methods #770

Merged
merged 1 commit into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 179 additions & 16 deletions src/frame/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,32 +554,38 @@ impl Pseudo {
pub fn request(method: Method, uri: Uri, protocol: Option<Protocol>) -> Self {
let parts = uri::Parts::from(uri);

let mut path = parts
.path_and_query
.map(|v| BytesStr::from(v.as_str()))
.unwrap_or(BytesStr::from_static(""));

match method {
Method::OPTIONS | Method::CONNECT => {}
_ if path.is_empty() => {
path = BytesStr::from_static("/");
}
_ => {}
}
let (scheme, path) = if method == Method::CONNECT && protocol.is_none() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handling of extended connect looks correct to me here

@seanmonstar should an attempt to make a malformed request be silently corrected or should we panic?

(None, None)
} else {
let path = parts
.path_and_query
.map(|v| BytesStr::from(v.as_str()))
.unwrap_or(BytesStr::from_static(""));

let path = if !path.is_empty() {
path
} else {
if method == Method::OPTIONS {
BytesStr::from_static("*")
} else {
BytesStr::from_static("/")
}
};

(parts.scheme, Some(path))
};

let mut pseudo = Pseudo {
method: Some(method),
scheme: None,
authority: None,
path: Some(path).filter(|p| !p.is_empty()),
path,
protocol,
status: None,
};

// If the URI includes a scheme component, add it to the pseudo headers
//
// TODO: Scheme must be set...
if let Some(scheme) = parts.scheme {
if let Some(scheme) = scheme {
pseudo.set_scheme(scheme);
}

Expand Down Expand Up @@ -1048,4 +1054,161 @@ mod test {
let mut buf = BytesMut::new();
huffman::decode(src, &mut buf).unwrap()
}

#[test]
fn test_connect_request_pseudo_headers_omits_path_and_scheme() {
// CONNECT requests MUST NOT include :scheme & :path pseudo-header fields
// See: https://datatracker.ietf.org/doc/html/rfc9113#section-8.5

assert_eq!(
Pseudo::request(
Method::CONNECT,
Uri::from_static("https://example.com:8443"),
None
),
Pseudo {
method: Method::CONNECT.into(),
authority: BytesStr::from_static("example.com:8443").into(),
..Default::default()
}
);

assert_eq!(
Pseudo::request(
Method::CONNECT,
Uri::from_static("https://example.com/test"),
None
),
Pseudo {
method: Method::CONNECT.into(),
authority: BytesStr::from_static("example.com").into(),
..Default::default()
}
);

assert_eq!(
Pseudo::request(Method::CONNECT, Uri::from_static("example.com:8443"), None),
Pseudo {
method: Method::CONNECT.into(),
authority: BytesStr::from_static("example.com:8443").into(),
..Default::default()
}
);
}

#[test]
fn test_extended_connect_request_pseudo_headers_includes_path_and_scheme() {
// On requests that contain the :protocol pseudo-header field, the
// :scheme and :path pseudo-header fields of the target URI (see
// Section 5) MUST also be included.
// See: https://datatracker.ietf.org/doc/html/rfc8441#section-4

assert_eq!(
Pseudo::request(
Method::CONNECT,
Uri::from_static("https://example.com:8443"),
Protocol::from_static("the-bread-protocol").into()
),
Pseudo {
method: Method::CONNECT.into(),
authority: BytesStr::from_static("example.com:8443").into(),
scheme: BytesStr::from_static("https").into(),
path: BytesStr::from_static("/").into(),
protocol: Protocol::from_static("the-bread-protocol").into(),
..Default::default()
}
);

assert_eq!(
Pseudo::request(
Method::CONNECT,
Uri::from_static("https://example.com:8443/test"),
Protocol::from_static("the-bread-protocol").into()
),
Pseudo {
method: Method::CONNECT.into(),
authority: BytesStr::from_static("example.com:8443").into(),
scheme: BytesStr::from_static("https").into(),
path: BytesStr::from_static("/test").into(),
protocol: Protocol::from_static("the-bread-protocol").into(),
..Default::default()
}
);

assert_eq!(
Pseudo::request(
Method::CONNECT,
Uri::from_static("http://example.com/a/b/c"),
Protocol::from_static("the-bread-protocol").into()
),
Pseudo {
method: Method::CONNECT.into(),
authority: BytesStr::from_static("example.com").into(),
scheme: BytesStr::from_static("http").into(),
path: BytesStr::from_static("/a/b/c").into(),
protocol: Protocol::from_static("the-bread-protocol").into(),
..Default::default()
}
);
}

#[test]
fn test_options_request_with_empty_path_has_asterisk_as_pseudo_path() {
// an OPTIONS request for an "http" or "https" URI that does not include a path component;
// these MUST include a ":path" pseudo-header field with a value of '*' (see Section 7.1 of [HTTP]).
// See: https://datatracker.ietf.org/doc/html/rfc9113#section-8.3.1
assert_eq!(
Pseudo::request(Method::OPTIONS, Uri::from_static("example.com:8080"), None,),
Pseudo {
method: Method::OPTIONS.into(),
authority: BytesStr::from_static("example.com:8080").into(),
path: BytesStr::from_static("*").into(),
..Default::default()
}
);
}

#[test]
fn test_non_option_and_non_connect_requests_include_path_and_scheme() {
let methods = [
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::HEAD,
Method::PATCH,
Method::TRACE,
];

for method in methods {
assert_eq!(
Pseudo::request(
method.clone(),
Uri::from_static("http://example.com:8080"),
None,
),
Pseudo {
method: method.clone().into(),
authority: BytesStr::from_static("example.com:8080").into(),
scheme: BytesStr::from_static("http").into(),
path: BytesStr::from_static("/").into(),
..Default::default()
}
);
assert_eq!(
Pseudo::request(
method.clone(),
Uri::from_static("https://example.com/a/b/c"),
None,
),
Pseudo {
method: method.into(),
authority: BytesStr::from_static("example.com").into(),
scheme: BytesStr::from_static("https").into(),
path: BytesStr::from_static("/a/b/c").into(),
..Default::default()
}
);
}
}
}
25 changes: 9 additions & 16 deletions tests/h2-support/src/frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@ use std::fmt;
use bytes::Bytes;
use http::{HeaderMap, StatusCode};

use h2::{
ext::Protocol,
frame::{self, Frame, StreamId},
};
use h2::frame::{self, Frame, StreamId};

pub const SETTINGS: &[u8] = &[0, 0, 0, 4, 0, 0, 0, 0, 0];
pub const SETTINGS_ACK: &[u8] = &[0, 0, 0, 4, 1, 0, 0, 0, 0];
Expand Down Expand Up @@ -124,19 +121,24 @@ impl Mock<frame::Headers> {
M::Error: fmt::Debug,
{
let method = method.try_into().unwrap();
let (id, _, fields) = self.into_parts();
let (id, pseudo, fields) = self.into_parts();
let frame = frame::Headers::new(
id,
frame::Pseudo {
scheme: None,
method: Some(method),
..Default::default()
..pseudo
},
fields,
);
Mock(frame)
}

pub fn pseudo(self, pseudo: frame::Pseudo) -> Self {
let (id, _, fields) = self.into_parts();
let frame = frame::Headers::new(id, pseudo, fields);
Mock(frame)
}

pub fn response<S>(self, status: S) -> Self
where
S: TryInto<http::StatusCode>,
Expand Down Expand Up @@ -184,15 +186,6 @@ impl Mock<frame::Headers> {
Mock(frame::Headers::new(id, pseudo, fields))
}

pub fn protocol(self, value: &str) -> Self {
let (id, mut pseudo, fields) = self.into_parts();
let value = Protocol::from(value);

pseudo.set_protocol(value);

Mock(frame::Headers::new(id, pseudo, fields))
}

pub fn eos(mut self) -> Self {
self.0.set_end_stream();
self
Expand Down
49 changes: 47 additions & 2 deletions tests/h2-tests/tests/client_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,45 @@ async fn http_2_request_without_scheme_or_authority() {
join(srv, h2).await;
}

#[tokio::test]
async fn http_2_connect_request_omit_scheme_and_path_fields() {
h2_support::trace_init!();
let (io, mut srv) = mock::new();

let srv = async move {
let settings = srv.assert_client_handshake().await;
assert_default_settings!(settings);
srv.recv_frame(
frames::headers(1)
.pseudo(frame::Pseudo {
method: Method::CONNECT.into(),
authority: util::byte_str("tunnel.example.com:8443").into(),
..Default::default()
})
.eos(),
)
.await;
srv.send_frame(frames::headers(1).response(200).eos()).await;
};

let h2 = async move {
let (mut client, mut h2) = client::handshake(io).await.expect("handshake");

// In HTTP_2 CONNECT request the ":scheme" and ":path" pseudo-header fields MUST be omitted.
let request = Request::builder()
.version(Version::HTTP_2)
.method(Method::CONNECT)
.uri("https://tunnel.example.com:8443/")
.body(())
.unwrap();

let (response, _) = client.send_request(request, true).unwrap();
h2.drive(response).await.unwrap();
};

join(srv, h2).await;
}

#[test]
#[ignore]
fn request_with_h1_version() {}
Expand Down Expand Up @@ -1521,8 +1560,14 @@ async fn extended_connect_request() {

srv.recv_frame(
frames::headers(1)
.request("CONNECT", "http://bread/baguette")
.protocol("the-bread-protocol")
.pseudo(frame::Pseudo {
method: Method::CONNECT.into(),
scheme: util::byte_str("http").into(),
authority: util::byte_str("bread").into(),
path: util::byte_str("/baguette").into(),
protocol: Protocol::from_static("the-bread-protocol").into(),
..Default::default()
})
.eos(),
)
.await;
Expand Down
Loading