diff --git a/apollo-router/src/axum_factory/axum_http_server_factory.rs b/apollo-router/src/axum_factory/axum_http_server_factory.rs index 0eeef7e09b6..3d2127c8040 100644 --- a/apollo-router/src/axum_factory/axum_http_server_factory.rs +++ b/apollo-router/src/axum_factory/axum_http_server_factory.rs @@ -501,7 +501,7 @@ where })); // Add header size limit middleware - if let Some(max_header_size) = configuration.server.http.max_header_size { + if let Some(max_header_size) = configuration.server.http.effective_max().header_size { tracing::debug!(?max_header_size, "Adding header size limit middleware"); router = router.layer(HeaderSizeLimitLayer::new(Some(max_header_size))); } diff --git a/apollo-router/src/axum_factory/listeners.rs b/apollo-router/src/axum_factory/listeners.rs index 491c1389f66..b1ee31216f5 100644 --- a/apollo-router/src/axum_factory/listeners.rs +++ b/apollo-router/src/axum_factory/listeners.rs @@ -313,15 +313,17 @@ fn get_effective_http_config( legacy_max_headers: Option, legacy_max_buf_size: Option, ) -> (Option, Option, Option, Option) { + let effective_max = server_config.effective_max(); + // For backward compatibility, prefer server config over legacy config - let effective_max_headers = server_config.max_headers.or(legacy_max_headers); + let effective_max_headers = effective_max.headers.or(legacy_max_headers); // Use legacy_max_buf_size for HTTP/1 buffer size (different from header size) let effective_max_buf_size = legacy_max_buf_size; // New server-specific configuration - let effective_max_header_size = server_config.max_header_size; - let effective_max_header_list_size = server_config.max_header_list_size; + let effective_max_header_size = effective_max.header_size; + let effective_max_header_list_size = effective_max.header_list_size; (effective_max_headers, effective_max_buf_size, effective_max_header_size, effective_max_header_list_size) } diff --git a/apollo-router/src/configuration/server.rs b/apollo-router/src/configuration/server.rs index 49c1d0c3e2f..9f4e9ea6f03 100644 --- a/apollo-router/src/configuration/server.rs +++ b/apollo-router/src/configuration/server.rs @@ -11,9 +11,31 @@ fn default_header_read_timeout() -> Duration { DEFAULT_HEADER_READ_TIMEOUT } -/// Configuration for HTTP +/// Configuration for HTTP limits #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields, default)] +pub(crate) struct ServerHttpMaxConfig { + /// Maximum size of a single header field (name + value) in bytes. + /// Applies to both HTTP/1.1 and HTTP/2. + /// If not specified, uses the underlying HTTP implementation's default. + #[schemars(with = "Option")] + pub(crate) header_size: Option, + + /// Maximum number of headers allowed in a request. + /// Applies primarily to HTTP/1.1 connections. + /// If not specified, uses the underlying HTTP implementation's default. + pub(crate) headers: Option, + + /// Maximum total size of all headers combined in bytes. + /// Applies primarily to HTTP/2 connections. + /// If not specified, uses the underlying HTTP implementation's default. + #[schemars(with = "Option")] + pub(crate) header_list_size: Option, +} + +/// Configuration for HTTP +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(default)] pub(crate) struct ServerHttpConfig { /// Header read timeout in human-readable format; defaults to 10s #[serde( @@ -23,20 +45,24 @@ pub(crate) struct ServerHttpConfig { #[schemars(with = "String", default = "default_header_read_timeout")] pub(crate) header_read_timeout: Duration, + /// Maximum limits for HTTP requests + pub(crate) max: ServerHttpMaxConfig, + + // Backward compatibility fields (deprecated, use max.* instead) /// Maximum size of a single header field (name + value) in bytes. - /// Applies to both HTTP/1.1 and HTTP/2. - /// If not specified, uses the underlying HTTP implementation's default. + /// Deprecated: Use max.header_size instead + #[serde(skip_serializing_if = "Option::is_none")] #[schemars(with = "Option")] pub(crate) max_header_size: Option, /// Maximum number of headers allowed in a request. - /// Applies primarily to HTTP/1.1 connections. - /// If not specified, uses the underlying HTTP implementation's default. + /// Deprecated: Use max.headers instead + #[serde(skip_serializing_if = "Option::is_none")] pub(crate) max_headers: Option, /// Maximum total size of all headers combined in bytes. - /// Applies primarily to HTTP/2 connections. - /// If not specified, uses the underlying HTTP implementation's default. + /// Deprecated: Use max.header_list_size instead + #[serde(skip_serializing_if = "Option::is_none")] #[schemars(with = "Option")] pub(crate) max_header_list_size: Option, } @@ -48,10 +74,21 @@ pub(crate) struct Server { pub(crate) http: ServerHttpConfig, } +impl Default for ServerHttpMaxConfig { + fn default() -> Self { + Self { + header_size: None, + headers: None, + header_list_size: None, + } + } +} + impl Default for ServerHttpConfig { fn default() -> Self { Self { header_read_timeout: Duration::from_secs(10), + max: ServerHttpMaxConfig::default(), max_header_size: None, max_headers: None, max_header_list_size: None, @@ -59,17 +96,47 @@ impl Default for ServerHttpConfig { } } +impl ServerHttpConfig { + /// Get effective max configuration, merging backward compatibility fields + pub(crate) fn effective_max(&self) -> ServerHttpMaxConfig { + ServerHttpMaxConfig { + header_size: self.max.header_size.or(self.max_header_size), + headers: self.max.headers.or(self.max_headers), + header_list_size: self.max.header_list_size.or(self.max_header_list_size), + } + } +} + +#[buildstructor::buildstructor] +impl ServerHttpMaxConfig { + #[builder] + pub(crate) fn new( + header_size: Option, + headers: Option, + header_list_size: Option, + ) -> Self { + Self { + header_size, + headers, + header_list_size, + } + } +} + #[buildstructor::buildstructor] impl ServerHttpConfig { #[builder] pub(crate) fn new( header_read_timeout: Option, + max: Option, + // Backward compatibility parameters max_header_size: Option, max_headers: Option, max_header_list_size: Option, ) -> Self { Self { header_read_timeout: header_read_timeout.unwrap_or_else(default_header_read_timeout), + max: max.unwrap_or_default(), max_header_size, max_headers, max_header_list_size, @@ -107,9 +174,9 @@ mod tests { server_config.http.header_read_timeout, default_duration_seconds ); - assert_eq!(server_config.http.max_header_size, None); - assert_eq!(server_config.http.max_headers, None); - assert_eq!(server_config.http.max_header_list_size, None); + assert_eq!(server_config.http.max.header_size, None); + assert_eq!(server_config.http.max.headers, None); + assert_eq!(server_config.http.max.header_list_size, None); } #[test] @@ -119,9 +186,9 @@ mod tests { let config: Server = serde_json::from_value(json_server).unwrap(); assert_eq!(config.http.header_read_timeout, Duration::from_secs(10)); - assert_eq!(config.http.max_header_size, None); - assert_eq!(config.http.max_headers, None); - assert_eq!(config.http.max_header_list_size, None); + assert_eq!(config.http.max.header_size, None); + assert_eq!(config.http.max.headers, None); + assert_eq!(config.http.max.header_list_size, None); } #[test] @@ -133,9 +200,9 @@ mod tests { let config: Server = serde_json::from_value(json_config).unwrap(); assert_eq!(config.http.header_read_timeout, Duration::from_secs(10)); - assert_eq!(config.http.max_header_size, None); - assert_eq!(config.http.max_headers, None); - assert_eq!(config.http.max_header_list_size, None); + assert_eq!(config.http.max.header_size, None); + assert_eq!(config.http.max.headers, None); + assert_eq!(config.http.max.header_list_size, None); } #[test] @@ -168,17 +235,19 @@ mod tests { fn it_json_parses_http_header_limits_correctly() { let json_config = json!({ "http": { - "max_header_size": "32kb", - "max_headers": 200, - "max_header_list_size": "64kb" + "max": { + "header_size": "32kb", + "headers": 200, + "header_list_size": "64kb" + } } }); let config: Server = serde_json::from_value(json_config).unwrap(); - assert_eq!(config.http.max_header_size, Some(ByteSize::kb(32))); - assert_eq!(config.http.max_headers, Some(200)); - assert_eq!(config.http.max_header_list_size, Some(ByteSize::kb(64))); + assert_eq!(config.http.max.header_size, Some(ByteSize::kb(32))); + assert_eq!(config.http.max.headers, Some(200)); + assert_eq!(config.http.max.header_list_size, Some(ByteSize::kb(64))); } #[test] @@ -186,17 +255,19 @@ mod tests { let json_config = json!({ "http": { "header_read_timeout": "15s", - "max_header_size": "16kb", - "max_headers": 150 + "max": { + "header_size": "16kb", + "headers": 150 + } } }); let config: Server = serde_json::from_value(json_config).unwrap(); assert_eq!(config.http.header_read_timeout, Duration::from_secs(15)); - assert_eq!(config.http.max_header_size, Some(ByteSize::kb(16))); - assert_eq!(config.http.max_headers, Some(150)); - assert_eq!(config.http.max_header_list_size, None); + assert_eq!(config.http.max.header_size, Some(ByteSize::kb(16))); + assert_eq!(config.http.max.headers, Some(150)); + assert_eq!(config.http.max.header_list_size, None); } #[test] @@ -204,26 +275,30 @@ mod tests { let json_config = json!({ "http": { "header_read_timeout": "20s", - "max_header_size": "32kb", - "max_headers": 200, - "max_header_list_size": "64kb" + "max": { + "header_size": "32kb", + "headers": 200, + "header_list_size": "64kb" + } } }); let config: Server = serde_json::from_value(json_config).unwrap(); assert_eq!(config.http.header_read_timeout, Duration::from_secs(20)); - assert_eq!(config.http.max_header_size, Some(ByteSize::kb(32))); - assert_eq!(config.http.max_headers, Some(200)); - assert_eq!(config.http.max_header_list_size, Some(ByteSize::kb(64))); + assert_eq!(config.http.max.header_size, Some(ByteSize::kb(32))); + assert_eq!(config.http.max.headers, Some(200)); + assert_eq!(config.http.max.header_list_size, Some(ByteSize::kb(64))); } #[test] fn test_server_http_config_partial_header_config() { let json_config = json!({ "http": { - "max_header_size": "64kb", - "max_headers": 500 + "max": { + "header_size": "64kb", + "headers": 500 + } } }); @@ -231,41 +306,108 @@ mod tests { // Default timeout should be preserved assert_eq!(config.http.header_read_timeout, Duration::from_secs(10)); - assert_eq!(config.http.max_header_size, Some(ByteSize::kb(64))); - assert_eq!(config.http.max_headers, Some(500)); - assert_eq!(config.http.max_header_list_size, None); + assert_eq!(config.http.max.header_size, Some(ByteSize::kb(64))); + assert_eq!(config.http.max.headers, Some(500)); + assert_eq!(config.http.max.header_list_size, None); } #[test] fn test_server_http_config_large_values() { let json_config = json!({ "http": { - "max_header_size": "1mb", - "max_headers": 1000, - "max_header_list_size": "10mb" + "max": { + "header_size": "1mb", + "headers": 1000, + "header_list_size": "10mb" + } } }); let config: Server = serde_json::from_value(json_config).unwrap(); - assert_eq!(config.http.max_header_size, Some(ByteSize::mb(1))); - assert_eq!(config.http.max_headers, Some(1000)); - assert_eq!(config.http.max_header_list_size, Some(ByteSize::mb(10))); + assert_eq!(config.http.max.header_size, Some(ByteSize::mb(1))); + assert_eq!(config.http.max.headers, Some(1000)); + assert_eq!(config.http.max.header_list_size, Some(ByteSize::mb(10))); } #[test] fn test_buildstructor_with_new_http_fields() { let http_config = ServerHttpConfig::builder() .header_read_timeout(Some(Duration::from_secs(30))) - .max_header_size(Some(ByteSize::kb(48))) - .max_headers(Some(300)) - .max_header_list_size(Some(ByteSize::kb(96))) + .max(Some(ServerHttpMaxConfig::builder() + .header_size(Some(ByteSize::kb(48))) + .headers(Some(300)) + .header_list_size(Some(ByteSize::kb(96))) + .build())) .build(); assert_eq!(http_config.header_read_timeout, Duration::from_secs(30)); - assert_eq!(http_config.max_header_size, Some(ByteSize::kb(48))); - assert_eq!(http_config.max_headers, Some(300)); - assert_eq!(http_config.max_header_list_size, Some(ByteSize::kb(96))); + assert_eq!(http_config.max.header_size, Some(ByteSize::kb(48))); + assert_eq!(http_config.max.headers, Some(300)); + assert_eq!(http_config.max.header_list_size, Some(ByteSize::kb(96))); + } + + #[test] + fn test_backward_compatibility_old_field_names() { + let json_config = json!({ + "http": { + "max_header_size": "32kb", + "max_headers": 200, + "max_header_list_size": "64kb" + } + }); + + let config: Server = serde_json::from_value(json_config).unwrap(); + let effective_max = config.http.effective_max(); + + assert_eq!(effective_max.header_size, Some(ByteSize::kb(32))); + assert_eq!(effective_max.headers, Some(200)); + assert_eq!(effective_max.header_list_size, Some(ByteSize::kb(64))); + } + + #[test] + fn test_new_max_structure_takes_precedence() { + let json_config = json!({ + "http": { + "max": { + "header_size": "64kb", + "headers": 300, + "header_list_size": "128kb" + }, + "max_header_size": "32kb", + "max_headers": 200, + "max_header_list_size": "64kb" + } + }); + + let config: Server = serde_json::from_value(json_config).unwrap(); + let effective_max = config.http.effective_max(); + + // New max structure should take precedence over old fields + assert_eq!(effective_max.header_size, Some(ByteSize::kb(64))); + assert_eq!(effective_max.headers, Some(300)); + assert_eq!(effective_max.header_list_size, Some(ByteSize::kb(128))); + } + + #[test] + fn test_mixed_old_and_new_configuration() { + let json_config = json!({ + "http": { + "max": { + "header_size": "64kb" + }, + "max_headers": 200, + "max_header_list_size": "64kb" + } + }); + + let config: Server = serde_json::from_value(json_config).unwrap(); + let effective_max = config.http.effective_max(); + + // Should use new max.header_size and fall back to old fields for others + assert_eq!(effective_max.header_size, Some(ByteSize::kb(64))); + assert_eq!(effective_max.headers, Some(200)); + assert_eq!(effective_max.header_list_size, Some(ByteSize::kb(64))); } #[test] 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 deleted file mode 100644 index bfab6b15e71..00000000000 --- a/apollo-router/src/plugins/limits/fixtures/header_count_limit.router.yaml +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 4c3ac204e34..00000000000 --- a/apollo-router/src/plugins/limits/fixtures/header_list_items_limit.router.yaml +++ /dev/null @@ -1,2 +0,0 @@ -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 b60ad009562..268f8d1c337 100644 --- a/apollo-router/src/plugins/limits/layer.rs +++ b/apollo-router/src/plugins/limits/layer.rs @@ -18,14 +18,6 @@ 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, @@ -424,113 +416,3 @@ 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 a162cf0cdb9..980cbc9d6b3 100644 --- a/apollo-router/src/plugins/limits/mod.rs +++ b/apollo-router/src/plugins/limits/mod.rs @@ -19,10 +19,7 @@ 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; @@ -119,20 +116,6 @@ 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" }` @@ -153,8 +136,6 @@ 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 @@ -193,12 +174,6 @@ 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, )) @@ -234,10 +209,7 @@ impl LimitsPlugin { } match root_cause.downcast_ref::() { - None => match root_cause.downcast_ref::() { - None => Err(e), - Some(header_error) => Ok(header_error.into_response(ctx)), - }, + None => Err(e), Some(_) => Ok(BodyLimitError::PayloadTooLarge.into_response(ctx)), } } @@ -264,37 +236,6 @@ 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)] @@ -489,121 +430,6 @@ 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 76c7bf111cf..50d79c0a81b 100644 --- a/apollo-router/tests/integration/supergraph.rs +++ b/apollo-router/tests/integration/supergraph.rs @@ -141,7 +141,8 @@ async fn test_supergraph_server_http_max_headers_exceeded() -> Result<(), BoxErr r#" server: http: - max_headers: 10 + max: + headers: 10 "#, ) .build() @@ -174,7 +175,8 @@ async fn test_supergraph_server_http_max_headers_within_limit() -> Result<(), Bo r#" server: http: - max_headers: 50 + max: + headers: 50 "#, ) .build() @@ -212,7 +214,8 @@ async fn test_supergraph_server_http_large_header_value() -> Result<(), BoxError r#" server: http: - max_header_size: 1kb + max: + header_size: 1kb "#, ) .build() @@ -245,7 +248,8 @@ async fn test_supergraph_server_http_header_size_within_limit() -> Result<(), Bo r#" server: http: - max_header_size: 2kb + max: + header_size: 2kb "#, ) .build() @@ -281,7 +285,8 @@ async fn test_supergraph_server_http_header_list_size_exceeded() -> Result<(), B r#" server: http: - max_header_list_size: 4kb + max: + header_list_size: 4kb "#, ) .build() @@ -317,7 +322,8 @@ async fn test_supergraph_server_http_header_list_size_within_limit() -> Result<( r#" server: http: - max_header_list_size: 8kb + max: + header_list_size: 8kb "#, ) .build() @@ -389,7 +395,8 @@ async fn test_supergraph_combined_config_server_takes_precedence() -> Result<(), r#" server: http: - max_headers: 20 + max: + headers: 20 limits: http1_max_request_headers: 5 "#, @@ -422,46 +429,18 @@ 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(()) -} +// Test backward compatibility with old field names #[tokio::test(flavor = "multi_thread")] -async fn test_supergraph_limits_http_max_request_headers_within_limit() -> Result<(), BoxError> { +async fn test_supergraph_server_http_backward_compatibility() -> Result<(), BoxError> { let mut router = IntegrationTest::builder() .config( r#" - limits: - http_max_request_headers: 10 + server: + http: + max_header_size: "1kb" + max_headers: 10 "#, ) .build() @@ -471,7 +450,7 @@ async fn test_supergraph_limits_http_max_request_headers_within_limit() -> Resul router.assert_started().await; let mut headers = HashMap::new(); - for i in 0..5 { + for i in 0..11 { headers.insert(format!("test-header-{i}"), format!("value_{i}")); } @@ -483,73 +462,6 @@ async fn test_supergraph_limits_http_max_request_headers_within_limit() -> Resul .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 eb0b660cd08..07a593ba478 100644 --- a/docs/source/routing/security/request-limits.mdx +++ b/docs/source/routing/security/request-limits.mdx @@ -17,10 +17,6 @@ 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 @@ -295,102 +291,62 @@ 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. +### HTTP Header Configuration (server.http) -Example configuration: +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: ```yaml title="router.yaml" -limits: - http_max_header_list_items: 5 # Allow up to 5 values per header +server: + http: + # Maximum limits for HTTP requests + max: + # Maximum size of individual header field (name + value) in bytes + # Applies to both HTTP/1.1 and HTTP/2 + header_size: "32kb" + + # Maximum number of headers allowed in a request + # Primarily affects HTTP/1.1 connections + headers: 250 + + # Maximum total size of all headers combined + # Primarily affects HTTP/2 connections + header_list_size: "64kb" + + # Header read timeout (applies to all HTTP versions) + header_read_timeout: "10s" ``` -If a request contains a header with more values than this limit, the router responds with `431 Request Header Fields Too Large`: +The `server.http` configuration takes precedence over the legacy `limits.http1_*` settings when both are specified. This allows you to: -```json5 -{ - "errors": [ - { - "message": "Request header list too many items", - "extensions": { - "details": "Request header list too many items", - "code": "INVALID_GRAPHQL_REQUEST" - } - } - ] -} -``` +- Set per-header size limits with `max.header_size` (similar to Node.js `--max-http-header-size`) +- Control header count limits with `max.headers` for HTTP/1.1 +- Set total header payload limits with `max.header_list_size` for HTTP/2 +- Configure header read timeouts consistently across all HTTP versions -### HTTP Header Configuration (server.http) +#### Deprecated Field Names -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: +For backward compatibility, the old flat field names are still supported but deprecated: -```yaml title="router.yaml" +```yaml title="router.yaml (deprecated)" server: http: - # Maximum size of individual header field (name + value) in bytes - # Applies to both HTTP/1.1 and HTTP/2 + # Deprecated: Use max.header_size instead max_header_size: "32kb" - # Maximum number of headers allowed in a request - # Primarily affects HTTP/1.1 connections + # Deprecated: Use max.headers instead max_headers: 250 - # Maximum total size of all headers combined - # Primarily affects HTTP/2 connections + # Deprecated: Use max.header_list_size instead max_header_list_size: "64kb" - - # Header read timeout (applies to all HTTP versions) - header_read_timeout: "10s" ``` -The `server.http` configuration takes precedence over the legacy `limits.http1_*` settings when both are specified. This allows you to: - -- Set per-header size limits with `max_header_size` (similar to Node.js `--max-http-header-size`) -- Control header count limits with `max_headers` for HTTP/1.1 -- Set total header payload limits with `max_header_list_size` for HTTP/2 -- Configure header read timeouts consistently across all HTTP versions +The new nested structure takes precedence when both are specified. 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. +#### Server-level Header Limits -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. +The Apollo Router enforces header limits at the HTTP server level using both the new `server.http.*` configuration and legacy `limits.http1_*` settings. These limits are protocol-specific and enforced early in the request processing pipeline for optimal performance. ## Parser-based limits