diff --git a/README.md b/README.md index 841761d..f338036 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,7 @@ async fn fetch_data( Available builder methods: - * `get(url)` / `post(url)` -- convenience starters + * `get(url)` / `post(url)` / `head(url)` -- convenience starters * `request(method, url)` -- arbitrary HTTP method * `.header(key, val)` -- add a header (repeatable) * `.body(bytes)` -- set the request body diff --git a/src/client.rs b/src/client.rs index 70ec27f..5b0b059 100644 --- a/src/client.rs +++ b/src/client.rs @@ -559,6 +559,14 @@ impl HttpClientState { crate::request::RequestBuilder::new(self, reqwest::Method::POST, url.into()) } + /// Starts building a HEAD request through the plugin's security pipeline. + /// + /// Returns a [`RequestBuilder`](crate::request::RequestBuilder) that can + /// be customized with headers, timeout, and retry settings before sending. + pub fn head(&self, url: impl Into) -> crate::request::RequestBuilder<'_> { + crate::request::RequestBuilder::new(self, reqwest::Method::HEAD, url.into()) + } + /// Starts building a request with an arbitrary HTTP method. pub fn request( &self, @@ -629,7 +637,15 @@ impl HttpClientState { let status = response.status(); let resp_headers = response.headers().clone(); - let body_bytes = self.read_body_with_limit(response).await?; + // HEAD responses have no body per RFC 9110 ยง9.3.2. The Content-Length + // header advertises the would-be GET body size, so passing through + // read_body_with_limit would spuriously reject HEADs against resources + // larger than max_response_body_size. + let body_bytes = if method == reqwest::Method::HEAD { + Vec::new() + } else { + self.read_body_with_limit(response).await? + }; Ok(RawResponse { status, @@ -1517,6 +1533,87 @@ mod tests { assert!(result.unwrap().is_empty()); } + // --- HEAD request tests --- + + #[tokio::test] + async fn test_head_succeeds_when_content_length_exceeds_body_limit() { + let server = MockServer::start().await; + + // HEAD response with Content-Length advertising a large would-be body. + // wiremock sends no body for the HEAD response (correct per spec). + Mock::given(method("HEAD")) + .and(path("/big")) + .respond_with(ResponseTemplate::new(200).insert_header("Content-Length", "100000000")) + .mount(&server) + .await; + + // Body limit (100 bytes) is far smaller than advertised Content-Length. + let state = build_localhost_test_state(10, 100); + let mut req = make_request(&localhost_url(&server, "/big")); + req.method = Some("HEAD".to_string()); + + let resp = state.execute(req).await.expect("HEAD must not be rejected"); + + assert_eq!(resp.metadata.status, 200); + assert!(resp.body.is_empty(), "HEAD body must be empty"); + } + + #[tokio::test] + async fn test_head_preserves_response_headers() { + let server = MockServer::start().await; + + Mock::given(method("HEAD")) + .and(path("/resource")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Length", "50000000") + .insert_header("Content-Type", "application/octet-stream") + .insert_header("ETag", "\"abc123\""), + ) + .mount(&server) + .await; + + let state = build_localhost_test_state(10, 100); + let mut req = make_request(&localhost_url(&server, "/resource")); + req.method = Some("HEAD".to_string()); + + let resp = state.execute(req).await.unwrap(); + + let headers = &resp.metadata.headers; + let get_header = |name: &str| { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .and_then(|(_, v)| v.first().map(String::as_str)) + }; + + assert_eq!(get_header("content-length"), Some("50000000")); + assert_eq!(get_header("content-type"), Some("application/octet-stream")); + assert_eq!(get_header("etag"), Some("\"abc123\"")); + } + + #[tokio::test] + async fn test_get_still_rejects_oversized_content_length() { + // Regression guard: HEAD bypass must not weaken the GET-path body limit. + let server = MockServer::start().await; + let body = "x".repeat(2000); + + Mock::given(method("GET")) + .and(path("/big")) + .respond_with(ResponseTemplate::new(200).set_body_string(&body)) + .mount(&server) + .await; + + let state = build_localhost_test_state(10, 100); + let req = make_request(&localhost_url(&server, "/big")); + let err = state.execute(req).await.unwrap_err(); + + assert!( + matches!(err, Error::ResponseTooLarge { .. }), + "expected ResponseTooLarge, got: {err:?}" + ); + } + // --- DNS rebinding tests --- #[tokio::test] diff --git a/src/request.rs b/src/request.rs index c014476..df39827 100644 --- a/src/request.rs +++ b/src/request.rs @@ -5,7 +5,8 @@ //! validation, streaming body limits, retry). //! //! Created by [`HttpClientState::get`](crate::client::HttpClientState::get), -//! [`HttpClientState::post`](crate::client::HttpClientState::post), or +//! [`HttpClientState::post`](crate::client::HttpClientState::post), +//! [`HttpClientState::head`](crate::client::HttpClientState::head), or //! [`HttpClientState::request`](crate::client::HttpClientState::request). //! //! # Examples @@ -34,7 +35,8 @@ use crate::response::Response; /// Fluent builder for Rust-side HTTP requests through the plugin security pipeline. /// /// Created by [`HttpClientState::get`](crate::client::HttpClientState::get), -/// [`HttpClientState::post`](crate::client::HttpClientState::post), or +/// [`HttpClientState::post`](crate::client::HttpClientState::post), +/// [`HttpClientState::head`](crate::client::HttpClientState::head), or /// [`HttpClientState::request`](crate::client::HttpClientState::request). /// Call [`send`](RequestBuilder::send) to execute the request. /// @@ -210,6 +212,10 @@ mod tests { assert_eq!(post.method, reqwest::Method::POST); + let head = state.head("https://example.com"); + + assert_eq!(head.method, reqwest::Method::HEAD); + let put = state.request(reqwest::Method::PUT, "https://example.com"); assert_eq!(put.method, reqwest::Method::PUT); @@ -281,6 +287,28 @@ mod tests { assert_eq!(resp.text().unwrap(), "created"); } + #[tokio::test] + async fn test_send_head_request() { + let server = wiremock::MockServer::start().await; + + wiremock::Mock::given(wiremock::matchers::method("HEAD")) + .and(wiremock::matchers::path("/resource")) + .respond_with(wiremock::ResponseTemplate::new(200).insert_header("content-length", "1024")) + .mount(&server) + .await; + + let state = test_state(); + let resp = state + .head(format!("{}/resource", server.uri())) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), reqwest::StatusCode::OK); + assert_eq!(resp.headers().get("content-length").unwrap(), "1024"); + assert!(resp.body().is_empty()); + } + #[tokio::test] async fn test_send_forbidden_header_rejected() { let state = test_state();