diff --git a/apollo-router/src/plugins/limits/fixtures/header_count_limit.router.yaml b/apollo-router/src/plugins/limits/fixtures/header_count_limit.router.yaml new file mode 100644 index 00000000000..bfab6b15e71 --- /dev/null +++ b/apollo-router/src/plugins/limits/fixtures/header_count_limit.router.yaml @@ -0,0 +1,2 @@ +limits: + http_max_request_headers: 5 \ No newline at end of file diff --git a/apollo-router/src/plugins/limits/fixtures/header_list_items_limit.router.yaml b/apollo-router/src/plugins/limits/fixtures/header_list_items_limit.router.yaml new file mode 100644 index 00000000000..4c3ac204e34 --- /dev/null +++ b/apollo-router/src/plugins/limits/fixtures/header_list_items_limit.router.yaml @@ -0,0 +1,2 @@ +limits: + http_max_header_list_items: 3 \ No newline at end of file diff --git a/apollo-router/src/plugins/limits/layer.rs b/apollo-router/src/plugins/limits/layer.rs index 268f8d1c337..b60ad009562 100644 --- a/apollo-router/src/plugins/limits/layer.rs +++ b/apollo-router/src/plugins/limits/layer.rs @@ -18,6 +18,14 @@ pub(super) enum BodyLimitError { PayloadTooLarge, } +#[derive(thiserror::Error, Debug, Display)] +pub(super) enum HeaderLimitError { + /// Request header fields too many + TooManyHeaders, + /// Request header list too many items + TooManyHeaderListItems, +} + struct BodyLimitControlInner { limit: AtomicUsize, current: AtomicUsize, @@ -416,3 +424,113 @@ mod test { } } } + +/// Layer that limits the number of headers in an HTTP request +pub(crate) struct RequestHeaderCountLimitLayer { + max_headers: Option, +} + +impl RequestHeaderCountLimitLayer { + pub(crate) fn new(max_headers: Option) -> Self { + Self { max_headers } + } +} + +impl Layer for RequestHeaderCountLimitLayer { + type Service = RequestHeaderCountLimit; + + fn layer(&self, inner: S) -> Self::Service { + RequestHeaderCountLimit::new(inner, self.max_headers) + } +} + +pub(crate) struct RequestHeaderCountLimit { + inner: S, + max_headers: Option, +} + +impl RequestHeaderCountLimit { + fn new(inner: S, max_headers: Option) -> Self { + Self { inner, max_headers } + } +} + +impl Service> for RequestHeaderCountLimit +where + S: Service, Response = http::Response>, + S::Error: From, +{ + type Response = S::Response; + type Error = S::Error; + type Future = futures::future::Either>, S::Future>; + + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + if let Some(max_headers) = self.max_headers { + if req.headers().len() > max_headers { + // This will be converted to the appropriate response by the error handling + return futures::future::Either::Left(futures::future::ready(Err(HeaderLimitError::TooManyHeaders.into()))); + } + } + futures::future::Either::Right(self.inner.call(req)) + } +} + +/// Layer that limits the number of items in header lists (for headers with multiple values) +pub(crate) struct RequestHeaderListItemsLimitLayer { + max_items: Option, +} + +impl RequestHeaderListItemsLimitLayer { + pub(crate) fn new(max_items: Option) -> Self { + Self { max_items } + } +} + +impl Layer for RequestHeaderListItemsLimitLayer { + type Service = RequestHeaderListItemsLimit; + + fn layer(&self, inner: S) -> Self::Service { + RequestHeaderListItemsLimit::new(inner, self.max_items) + } +} + +pub(crate) struct RequestHeaderListItemsLimit { + inner: S, + max_items: Option, +} + +impl RequestHeaderListItemsLimit { + fn new(inner: S, max_items: Option) -> Self { + Self { inner, max_items } + } +} + +impl Service> for RequestHeaderListItemsLimit +where + S: Service, Response = http::Response>, + S::Error: From, +{ + type Response = S::Response; + type Error = S::Error; + type Future = futures::future::Either>, S::Future>; + + fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + if let Some(max_items) = self.max_items { + for (header_name, _) in req.headers().iter() { + let header_values_count = req.headers().get_all(header_name).iter().count(); + if header_values_count > max_items { + return futures::future::Either::Left(futures::future::ready(Err(HeaderLimitError::TooManyHeaderListItems.into()))); + } + } + } + futures::future::Either::Right(self.inner.call(req)) + } +} diff --git a/apollo-router/src/plugins/limits/mod.rs b/apollo-router/src/plugins/limits/mod.rs index 980cbc9d6b3..a162cf0cdb9 100644 --- a/apollo-router/src/plugins/limits/mod.rs +++ b/apollo-router/src/plugins/limits/mod.rs @@ -19,7 +19,10 @@ use crate::layers::ServiceBuilderExt; use crate::plugin::Plugin; use crate::plugin::PluginInit; use crate::plugins::limits::layer::BodyLimitError; +use crate::plugins::limits::layer::HeaderLimitError; use crate::plugins::limits::layer::RequestBodyLimitLayer; +use crate::plugins::limits::layer::RequestHeaderCountLimitLayer; +use crate::plugins::limits::layer::RequestHeaderListItemsLimitLayer; use crate::services::router; use crate::services::router::BoxService; @@ -116,6 +119,20 @@ pub(crate) struct Config { #[schemars(with = "Option", default)] pub(crate) http1_max_request_buf_size: Option, + /// Limit the maximum number of headers in an HTTP request. + /// + /// If router receives more headers than this limit, it responds to the client with + /// "431 Request Header Fields Too Large". + /// When not specified, no limit is enforced at the middleware level. + pub(crate) http_max_request_headers: Option, + + /// Limit the maximum number of items in a header list (for headers with multiple values). + /// + /// If a single header has more values than this limit, it responds to the client with + /// "431 Request Header Fields Too Large". + /// When not specified, no limit is enforced at the middleware level. + pub(crate) http_max_header_list_items: Option, + /// Limit the depth of nested list fields in introspection queries /// to protect avoid generating huge responses. Returns a GraphQL /// error with `{ message: "Maximum introspection depth exceeded" }` @@ -136,6 +153,8 @@ impl Default for Config { http_max_request_bytes: 2_000_000, http1_max_request_headers: None, http1_max_request_buf_size: None, + http_max_request_headers: None, + http_max_header_list_items: None, parser_max_tokens: 15_000, // This is `apollo-parser`’s default, which protects against stack overflow @@ -174,6 +193,12 @@ impl Plugin for LimitsPlugin { // Here we need to convert to and from the underlying http request types so that we can use existing middleware. .map_request(Into::into) .map_response(Into::into) + .layer(RequestHeaderCountLimitLayer::new( + self.config.http_max_request_headers, + )) + .layer(RequestHeaderListItemsLimitLayer::new( + self.config.http_max_header_list_items, + )) .layer(RequestBodyLimitLayer::new( self.config.http_max_request_bytes, )) @@ -209,7 +234,10 @@ impl LimitsPlugin { } match root_cause.downcast_ref::() { - None => Err(e), + None => match root_cause.downcast_ref::() { + None => Err(e), + Some(header_error) => Ok(header_error.into_response(ctx)), + }, Some(_) => Ok(BodyLimitError::PayloadTooLarge.into_response(ctx)), } } @@ -236,6 +264,37 @@ impl BodyLimitError { } } +impl HeaderLimitError { + fn into_response(&self, ctx: Context) -> router::Response { + match self { + HeaderLimitError::TooManyHeaders => router::Response::error_builder() + .error( + graphql::Error::builder() + .message("Request header fields too many") + .extension_code("INVALID_GRAPHQL_REQUEST") + .extension("details", "Request header fields too many") + .build(), + ) + .status_code(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE) + .context(ctx) + .build() + .unwrap(), + HeaderLimitError::TooManyHeaderListItems => router::Response::error_builder() + .error( + graphql::Error::builder() + .message("Request header list too many items") + .extension_code("INVALID_GRAPHQL_REQUEST") + .extension("details", "Request header list too many items") + .build(), + ) + .status_code(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE) + .context(ctx) + .build() + .unwrap(), + } + } +} + register_plugin!("apollo", "limits", LimitsPlugin); #[cfg(test)] @@ -430,6 +489,121 @@ mod test { ); } + #[tokio::test] + async fn test_header_count_limit_exceeded() { + let plugin = header_count_plugin().await; + let resp = plugin + .router_service(|_| async { panic!("should have rejected request") }) + .call( + router::Request::fake_builder() + .header("header1", "value1") + .header("header2", "value2") + .header("header3", "value3") + .header("header4", "value4") + .header("header5", "value5") + .header("header6", "value6") // This should exceed the limit of 5 + .body(router::body::empty()) + .build() + .unwrap(), + ) + .await; + assert!(resp.is_ok()); + let resp = resp.unwrap(); + assert_eq!(resp.response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE); + let body_str = String::from_utf8( + router::body::into_bytes(resp.response.into_body()) + .await + .unwrap() + .to_vec() + ).unwrap(); + assert!(body_str.contains("Request header fields too many")); + } + + #[tokio::test] + async fn test_header_count_limit_ok() { + let plugin = header_count_plugin().await; + let resp = plugin + .router_service(|_| async { Ok(router::Response::fake_builder().build().unwrap()) }) + .call( + router::Request::fake_builder() + .header("header1", "value1") + .header("header2", "value2") + .header("header3", "value3") + .body(router::body::empty()) + .build() + .unwrap(), + ) + .await; + assert!(resp.is_ok()); + let resp = resp.unwrap(); + assert_eq!(resp.response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_header_list_items_limit_exceeded() { + let plugin = header_list_items_plugin().await; + let mut request = router::Request::fake_builder() + .body(router::body::empty()); + + // Create a request with a header that has 4 values (exceeds limit of 3) + for i in 1..=4 { + request = request.header("test-header", format!("value{}", i)); + } + + let resp = plugin + .router_service(|_| async { panic!("should have rejected request") }) + .call(request.build().unwrap()) + .await; + assert!(resp.is_ok()); + let resp = resp.unwrap(); + assert_eq!(resp.response.status(), StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE); + let body_str = String::from_utf8( + router::body::into_bytes(resp.response.into_body()) + .await + .unwrap() + .to_vec() + ).unwrap(); + assert!(body_str.contains("Request header list too many items")); + } + + #[tokio::test] + async fn test_header_list_items_limit_ok() { + let plugin = header_list_items_plugin().await; + let mut request = router::Request::fake_builder() + .body(router::body::empty()); + + // Create a request with a header that has 2 values (within limit of 3) + for i in 1..=2 { + request = request.header("test-header", format!("value{}", i)); + } + + let resp = plugin + .router_service(|_| async { Ok(router::Response::fake_builder().build().unwrap()) }) + .call(request.build().unwrap()) + .await; + assert!(resp.is_ok()); + let resp = resp.unwrap(); + assert_eq!(resp.response.status(), StatusCode::OK); + } + + async fn header_count_plugin() -> PluginTestHarness { + let plugin: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("fixtures/header_count_limit.router.yaml")) + .build() + .await + .expect("test harness"); + plugin + } + + async fn header_list_items_plugin() -> PluginTestHarness { + let plugin: PluginTestHarness = PluginTestHarness::builder() + .config(include_str!("fixtures/header_list_items_limit.router.yaml")) + .build() + .await + .expect("test harness"); + plugin + } + async fn plugin() -> PluginTestHarness { let plugin: PluginTestHarness = PluginTestHarness::builder() .config(include_str!("fixtures/content_length_limit.router.yaml")) diff --git a/apollo-router/tests/integration/supergraph.rs b/apollo-router/tests/integration/supergraph.rs index b8546d3461e..76c7bf111cf 100644 --- a/apollo-router/tests/integration/supergraph.rs +++ b/apollo-router/tests/integration/supergraph.rs @@ -421,3 +421,135 @@ async fn test_supergraph_combined_config_server_takes_precedence() -> Result<(), ); Ok(()) } + +// Tests for new middleware-level header limits in the limits plugin +#[tokio::test(flavor = "multi_thread")] +async fn test_supergraph_limits_http_max_request_headers_exceeded() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" + limits: + http_max_request_headers: 5 + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let mut headers = HashMap::new(); + for i in 0..6 { + headers.insert(format!("test-header-{i}"), format!("value_{i}")); + } + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .headers(headers) + .build(), + ) + .await; + assert_eq!(response.status(), 431); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_supergraph_limits_http_max_request_headers_within_limit() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" + limits: + http_max_request_headers: 10 + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + let mut headers = HashMap::new(); + for i in 0..5 { + headers.insert(format!("test-header-{i}"), format!("value_{i}")); + } + + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .headers(headers) + .build(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ "data": { "__typename": "Query" } }) + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_supergraph_limits_http_max_header_list_items_exceeded() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" + limits: + http_max_header_list_items: 2 + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // Add multiple values to the same header (more than 2) + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .header("test-header", "value1") + .header("test-header", "value2") + .header("test-header", "value3") // This should exceed the limit + .build(), + ) + .await; + assert_eq!(response.status(), 431); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_supergraph_limits_http_max_header_list_items_within_limit() -> Result<(), BoxError> { + let mut router = IntegrationTest::builder() + .config( + r#" + limits: + http_max_header_list_items: 5 + "#, + ) + .build() + .await; + + router.start().await; + router.assert_started().await; + + // Add 2 values to the same header (within limit of 5) + let (_trace_id, response) = router + .execute_query( + Query::builder() + .body(json!({ "query": "{ __typename }"})) + .header("test-header", "value1") + .header("test-header", "value2") + .build(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + response.json::().await?, + json!({ "data": { "__typename": "Query" } }) + ); + Ok(()) +} diff --git a/docs/source/routing/security/request-limits.mdx b/docs/source/routing/security/request-limits.mdx index 92d723dc4fb..eb0b660cd08 100644 --- a/docs/source/routing/security/request-limits.mdx +++ b/docs/source/routing/security/request-limits.mdx @@ -17,6 +17,10 @@ limits: http_max_request_bytes: 2000000 # Default value: 2 MB http1_max_request_headers: 200 # Default value: 100 http1_max_request_buf_size: 800kb # Default value: 400kib + + # New middleware-level header limits + http_max_request_headers: 100 # Limit total number of headers in a request + http_max_header_list_items: 10 # Limit number of values per header field # Parser-based limits parser_max_tokens: 15000 # Default value @@ -291,6 +295,61 @@ If router receives more headers than the buffer size, it responds to the client Limit the maximum buffer size for the HTTP1 connection. Default is ~400kib. +### `http_max_request_headers` + +**New in v2.6+** - Middleware-level limit for the maximum number of headers in an HTTP request. + +This limit is enforced at the application layer (middleware) and applies to all HTTP versions (HTTP/1.1, HTTP/2, etc.). Unlike `http1_max_request_headers` which is enforced at the HTTP server level, this middleware-level limit provides more consistent behavior across different HTTP protocols. + +When not specified, no limit is enforced at the middleware level. + +If a request contains more headers than this limit, the router responds with `431 Request Header Fields Too Large`: + +```json5 +{ + "errors": [ + { + "message": "Request header fields too many", + "extensions": { + "details": "Request header fields too many", + "code": "INVALID_GRAPHQL_REQUEST" + } + } + ] +} +``` + +### `http_max_header_list_items` + +**New in v2.6+** - Middleware-level limit for the maximum number of values allowed per header field. + +This limit is enforced when a single header field contains multiple values (e.g., `Accept: application/json, text/html, application/xml`). If any header field contains more values than this limit, the router responds with `431 Request Header Fields Too Large`. + +When not specified, no limit is enforced at the middleware level. + +Example configuration: + +```yaml title="router.yaml" +limits: + http_max_header_list_items: 5 # Allow up to 5 values per header +``` + +If a request contains a header with more values than this limit, the router responds with `431 Request Header Fields Too Large`: + +```json5 +{ + "errors": [ + { + "message": "Request header list too many items", + "extensions": { + "details": "Request header list too many items", + "code": "INVALID_GRAPHQL_REQUEST" + } + } + ] +} +``` + ### HTTP Header Configuration (server.http) As an alternative to the legacy `http1_*` limits above, you can configure HTTP header limits using the `server.http` section, which provides more comprehensive control over both HTTP/1.1 and HTTP/2 connections: @@ -323,6 +382,16 @@ The `server.http` configuration takes precedence over the legacy `limits.http1_* If router receives requests exceeding these limits, it responds with `431 Request Header Fields Too Large`. +#### Middleware-level vs Server-level Header Limits + +The Apollo Router provides two approaches for limiting headers: + +1. **Server-level limits** (`server.http.*` and legacy `limits.http1_*`): Enforced by the underlying HTTP server implementation. These are protocol-specific and enforced early in the request processing pipeline. + +2. **Middleware-level limits** (`limits.http_max_request_headers` and `limits.http_max_header_list_items`): Enforced by Apollo Router middleware after the HTTP request is parsed but before GraphQL processing. These provide consistent behavior across all HTTP versions and can be dynamically configured. + +Choose server-level limits for maximum performance (earlier rejection) and choose middleware-level limits for consistent behavior across HTTP versions and more sophisticated routing logic. + ## Parser-based limits ### `parser_max_tokens`