Apollo Router's Compressed Payloads do not respect HTTP Payload Limits
Description
The Apollo Router is a graph router written in Rust to run a federated supergraph that uses Apollo Federation. Versions 0.9.5 until 1.40.2 are subject to a Denial-of-Service (DoS) type vulnerability. When receiving compressed HTTP payloads, affected versions of the Router evaluate the limits.http_max_request_bytes configuration option after the entirety of the compressed payload is decompressed. If affected versions of the Router receive highly compressed payloads, this could result in significant memory consumption while the compressed payload is expanded. Router version 1.40.2 has a fix for the vulnerability. Those who are unable to upgrade may be able to implement mitigations at proxies or load balancers positioned in front of their Router fleet (e.g. Nginx, HAProxy, or cloud-native WAF services) by creating limits on HTTP body upload size.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A DoS vulnerability in Apollo Router allows attackers to exhaust memory by sending highly compressed payloads that bypass size limits until decompression.
Vulnerability
Description
The Apollo Router, a configurable graph router for federated supergraphs written in Rust, is subject to a denial-of-service (DoS) vulnerability in versions 0.9.5 through 1.40.1. The root cause lies in the order of operations when processing compressed HTTP payloads: the router evaluates the limits.http_max_request_bytes configuration option *after* the entire compressed payload has been decompressed [3]. This allows an attacker to send a small, highly compressed request that expands to consume significant memory during decompression, bypassing the configured byte limit [3].
Exploitation
Exploitation requires the ability to send HTTP requests to an affected Apollo Router instance. The attack surface is remote and does not require authentication. An attacker crafts compressed HTTP requests (e.g., using gzip or other compression schemes) with a high compression ratio, causing the router to allocate and expand the payload in memory before checking the size limit [3]. No special privileges or network position is required beyond network access to the router's endpoint.
Impact
Successful exploitation can lead to memory exhaustion on the router process, resulting in a denial-of-service condition. This can degrade or completely block legitimate graph query processing, affecting all federated services served by that router instance [3]. The vulnerability does not require authentication and can be triggered by a single or small number of carefully crafted requests.
Mitigation
Version 1.40.2 of the Apollo Router includes a fix that ensures size limits are applied to compressed payloads before or during decompression [1][2]. Users unable to upgrade can implement mitigations at proxies or load balancers positioned in front of the router fleet—such as Nginx, HAProxy, or cloud-native WAF services—by enforcing limits on HTTP body upload size [3]. No other workarounds have been released.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
apollo-routercrates.io | >= 0.9.5, < 1.40.2 | 1.40.2 |
Affected products
2- apollographql/routerv5Range: >= 0.9.5, < 1.40.2
Patches
19e9527c73c8fstreaming body decompression
6 files changed · +61 −108
apollo-router/src/axum_factory/axum_http_server_factory.rs+34 −13 modified@@ -6,6 +6,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::Instant; +use axum::error_handling::HandleErrorLayer; use axum::extract::Extension; use axum::extract::State; use axum::http::StatusCode; @@ -32,14 +33,15 @@ use tokio::sync::mpsc; use tokio_rustls::TlsAcceptor; use tower::service_fn; use tower::BoxError; +use tower::ServiceBuilder; use tower::ServiceExt; +use tower_http::decompression::DecompressionBody; use tower_http::trace::TraceLayer; use super::listeners::ensure_endpoints_consistency; use super::listeners::ensure_listenaddrs_consistency; use super::listeners::extra_endpoints; use super::listeners::ListenersAndRouters; -use super::utils::decompress_request_body; use super::utils::PropagatingMakeSpan; use super::ListenAddrAndRouter; use super::ENDPOINT_CALLBACK; @@ -57,6 +59,7 @@ use crate::plugins::traffic_shaping::RateLimited; use crate::router::ApolloRouterError; use crate::router_factory::Endpoint; use crate::router_factory::RouterFactory; +use crate::services::http::service::BodyStream; use crate::services::router; use crate::uplink::license_enforcement::LicenseState; use crate::uplink::license_enforcement::APOLLO_ROUTER_LICENSE_EXPIRED; @@ -173,11 +176,9 @@ where tracing::trace!(?health, request = ?req.router_request, "health check"); async move { Ok(router::Response { - response: http::Response::builder() - .status(status_code) - .body::<hyper::Body>( - serde_json::to_vec(&health).map_err(BoxError::from)?.into(), - )?, + response: http::Response::builder().status(status_code).body::<Body>( + serde_json::to_vec(&health).map_err(BoxError::from)?.into(), + )?, context: req.context, }) } @@ -422,6 +423,10 @@ pub(crate) fn span_mode(configuration: &Configuration) -> SpanMode { .unwrap_or_default() } +async fn decompression_error(_error: BoxError) -> axum::response::Response { + (StatusCode::BAD_REQUEST, "cannot decompress request body").into_response() +} + fn main_endpoint<RF>( service_factory: RF, configuration: &Configuration, @@ -436,8 +441,16 @@ where })?; let span_mode = span_mode(configuration); + let decompression = ServiceBuilder::new() + .layer(HandleErrorLayer::<_, ()>::new(decompression_error)) + .layer( + tower_http::decompression::RequestDecompressionLayer::new() + .br(true) + .gzip(true) + .deflate(true), + ); let mut main_route = main_router::<RF>(configuration) - .layer(middleware::from_fn(decompress_request_body)) + .layer(decompression) .layer(middleware::from_fn_with_state( (license, Instant::now(), Arc::new(AtomicU64::new(0))), license_handler, @@ -530,19 +543,21 @@ async fn license_handler<B>( } } -pub(super) fn main_router<RF>(configuration: &Configuration) -> axum::Router +pub(super) fn main_router<RF>( + configuration: &Configuration, +) -> axum::Router<(), DecompressionBody<Body>> where RF: RouterFactory, { let mut router = Router::new().route( &configuration.supergraph.sanitized_path(), get({ - move |Extension(service): Extension<RF>, request: Request<Body>| { + move |Extension(service): Extension<RF>, request: Request<DecompressionBody<Body>>| { handle_graphql(service.create().boxed(), request) } }) .post({ - move |Extension(service): Extension<RF>, request: Request<Body>| { + move |Extension(service): Extension<RF>, request: Request<DecompressionBody<Body>>| { handle_graphql(service.create().boxed(), request) } }), @@ -552,12 +567,14 @@ where router = router.route( "/", get({ - move |Extension(service): Extension<RF>, request: Request<Body>| { + move |Extension(service): Extension<RF>, + request: Request<DecompressionBody<Body>>| { handle_graphql(service.create().boxed(), request) } }) .post({ - move |Extension(service): Extension<RF>, request: Request<Body>| { + move |Extension(service): Extension<RF>, + request: Request<DecompressionBody<Body>>| { handle_graphql(service.create().boxed(), request) } }), @@ -569,10 +586,14 @@ where async fn handle_graphql( service: router::BoxService, - http_request: Request<Body>, + http_request: Request<DecompressionBody<Body>>, ) -> impl IntoResponse { let _guard = SessionCountGuard::start(); + let (parts, body) = http_request.into_parts(); + + let http_request = http::Request::from_parts(parts, Body::wrap_stream(BodyStream::new(body))); + let request: router::Request = http_request.into(); let context = request.context.clone(); let accept_encoding = request
apollo-router/src/axum_factory/utils.rs+0 −88 modified@@ -2,19 +2,8 @@ use std::net::SocketAddr; -use async_compression::tokio::write::BrotliDecoder; -use async_compression::tokio::write::GzipDecoder; -use async_compression::tokio::write::ZlibDecoder; -use axum::http::StatusCode; -use axum::middleware::Next; -use axum::response::*; -use futures::prelude::*; -use http::header::CONTENT_ENCODING; -use http::Request; -use hyper::Body; use opentelemetry::global; use opentelemetry::trace::TraceContextExt; -use tokio::io::AsyncWriteExt; use tower_http::trace::MakeSpan; use tower_service::Service; use tracing::Span; @@ -26,83 +15,6 @@ use crate::uplink::license_enforcement::LICENSE_EXPIRED_SHORT_MESSAGE; pub(crate) const REQUEST_SPAN_NAME: &str = "request"; -pub(super) async fn decompress_request_body( - req: Request<Body>, - next: Next<Body>, -) -> Result<Response, Response> { - let (parts, body) = req.into_parts(); - let content_encoding = parts.headers.get(&CONTENT_ENCODING); - macro_rules! decode_body { - ($decoder: ident, $error_message: expr) => {{ - let body_bytes = hyper::body::to_bytes(body) - .map_err(|err| { - ( - StatusCode::BAD_REQUEST, - format!("cannot read request body: {err}"), - ) - .into_response() - }) - .await?; - let mut decoder = $decoder::new(Vec::new()); - decoder.write_all(&body_bytes).await.map_err(|err| { - ( - StatusCode::BAD_REQUEST, - format!("{}: {err}", $error_message), - ) - .into_response() - })?; - decoder.shutdown().await.map_err(|err| { - ( - StatusCode::BAD_REQUEST, - format!("{}: {err}", $error_message), - ) - .into_response() - })?; - - Ok(next - .run(Request::from_parts(parts, Body::from(decoder.into_inner()))) - .await) - }}; - } - - match content_encoding { - Some(content_encoding) => match content_encoding.to_str() { - Ok(content_encoding_str) => match content_encoding_str { - "br" => decode_body!(BrotliDecoder, "cannot decompress (brotli) request body"), - "gzip" => decode_body!(GzipDecoder, "cannot decompress (gzip) request body"), - "deflate" => decode_body!(ZlibDecoder, "cannot decompress (deflate) request body"), - "identity" => Ok(next.run(Request::from_parts(parts, body)).await), - unknown => { - let message = format!("unknown content-encoding header value {unknown:?}"); - tracing::error!(message); - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = StatusCode::BAD_REQUEST.as_u16() as i64, - error = message.clone() - ); - - Err((StatusCode::BAD_REQUEST, message).into_response()) - } - }, - - Err(err) => { - let message = format!("cannot read content-encoding header: {err}"); - u64_counter!( - "apollo_router_http_requests_total", - "Total number of HTTP requests made.", - 1, - status = 400, - error = message.clone() - ); - Err((StatusCode::BAD_REQUEST, message).into_response()) - } - }, - None => Ok(next.run(Request::from_parts(parts, body)).await), - } -} - #[derive(Clone, Default)] pub(crate) struct PropagatingMakeSpan { pub(crate) license: LicenseState,
apollo-router/src/services/http/service.rs+7 −0 modified@@ -391,6 +391,13 @@ pin_project! { } } +impl<B: hyper::body::HttpBody> BodyStream<B> { + /// Create a new `BodyStream`. + pub(crate) fn new(body: DecompressionBody<B>) -> Self { + Self { inner: body } + } +} + impl<B> Stream for BodyStream<B> where B: hyper::body::HttpBody,
apollo-router/src/services/layers/content_negotiation.rs+11 −0 modified@@ -71,6 +71,17 @@ where .to_string(), )) .expect("cannot fail"); + u64_counter!( + "apollo_router_http_requests_total", + "Total number of HTTP requests made.", + 1, + status = StatusCode::UNSUPPORTED_MEDIA_TYPE.as_u16() as i64, + error = format!( + r#"'content-type' header must be one of: {:?} or {:?}"#, + APPLICATION_JSON.essence_str(), + GRAPHQL_JSON_RESPONSE_HEADER_VALUE, + ) + ); return Ok(ControlFlow::Break(response.into())); }
apollo-router/tests/common.rs+6 −5 modified@@ -10,7 +10,6 @@ use std::time::SystemTime; use buildstructor::buildstructor; use http::header::ACCEPT; -use http::header::CONTENT_ENCODING; use http::header::CONTENT_TYPE; use http::HeaderValue; use jsonpath_lib::Selector; @@ -378,7 +377,7 @@ impl IntegrationTest { } #[allow(dead_code)] - pub fn execute_bad_content_encoding( + pub fn execute_bad_content_type( &self, ) -> impl std::future::Future<Output = (String, reqwest::Response)> { self.execute_query_internal(&json!({"garbage":{}}), Some("garbage")) @@ -387,7 +386,7 @@ impl IntegrationTest { fn execute_query_internal( &self, query: &Value, - content_encoding: Option<&'static str>, + content_type: Option<&'static str>, ) -> impl std::future::Future<Output = (String, reqwest::Response)> { assert!( self.router.is_some(), @@ -404,8 +403,10 @@ impl IntegrationTest { let mut request = client .post("http://localhost:4000") - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .header(CONTENT_ENCODING, content_encoding.unwrap_or("identity")) + .header( + CONTENT_TYPE, + content_type.unwrap_or(APPLICATION_JSON.essence_str()), + ) .header("apollographql-client-name", "custom_name") .header("apollographql-client-version", "1.0") .header("x-my-header", "test")
apollo-router/tests/telemetry/metrics.rs+3 −2 modified@@ -135,10 +135,11 @@ async fn test_bad_queries() { None, ) .await; - router.execute_bad_content_encoding().await; + router.execute_bad_content_type().await; + router .assert_metrics_contains( - r#"apollo_router_http_requests_total{error="unknown content-encoding header value \"garbage\"",status="400",otel_scope_name="apollo/router"}"#, + r#"apollo_router_http_requests_total{error="'content-type' header must be one of: \"application/json\" or \"application/graphql-response+json\"",status="415",otel_scope_name="apollo/router"}"#, None, ) .await;
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3- github.com/advisories/GHSA-cgqf-3cq5-wvcjghsaADVISORY
- github.com/apollographql/router/commit/9e9527c73c8f34fc8438b09066163cd42520f413ghsax_refsource_MISCWEB
- github.com/apollographql/router/security/advisories/GHSA-cgqf-3cq5-wvcjghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.