From 237386a3113edd78d4be0b19821cd3a9043e3f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Thu, 23 Apr 2026 14:36:27 -0300 Subject: [PATCH 1/2] fix: limit indexer response size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- src/config.rs | 7 +++++++ src/indexer_client.rs | 43 ++++++++++++++++++++++++++++++++++++++----- src/main.rs | 1 + 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 983c670b..0d5793f1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -69,6 +69,13 @@ pub struct Config { pub subgraph_service: Address, /// x402 payment support configuration pub x402: Option, + /// Maximum response size from indexers in bytes (default: 50MB) + #[serde(default = "default_max_indexer_response_size")] + pub max_indexer_response_size: usize, +} + +fn default_max_indexer_response_size() -> usize { + 50 * 1024 * 1024 // 50 MB } /// API keys configuration. diff --git a/src/indexer_client.rs b/src/indexer_client.rs index a5712ddc..f5f2d84f 100644 --- a/src/indexer_client.rs +++ b/src/indexer_client.rs @@ -1,4 +1,4 @@ -use http::{StatusCode, header::CONTENT_TYPE}; +use http::{StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE}}; use reqwest::header::AUTHORIZATION; use serde::{Deserialize, Serialize}; use thegraph_core::{ @@ -31,6 +31,9 @@ pub struct IndexerResponse { #[derive(Clone)] pub struct IndexerClient { pub client: reqwest::Client, + /// Maximum allowed response size from indexers. + /// Prevents memory exhaustion from malicious or buggy indexers. + pub max_response_size: usize, } pub enum IndexerAuth<'a> { @@ -79,6 +82,23 @@ impl IndexerClient { tracing::debug!(indexed_block = indexed_block.unwrap_or("null")); let indexed_block = indexed_block.and_then(parse_graph_indexed_header); + // Fast-path rejection: check Content-Length header before reading body + let max_response_size = self.max_response_size; + if let Some(content_length) = response.headers().get(CONTENT_LENGTH) { + if let Ok(len) = content_length.to_str().unwrap_or("0").parse::() { + if len > max_response_size { + return Err(BadResponse(format!( + "response too large: {len} bytes (max {max_response_size})" + ))); + } + } + } + + // Read body with size limit to prevent memory exhaustion + let body = read_body_limited(response, max_response_size) + .await + .map_err(BadResponse)?; + #[derive(Debug, Deserialize)] pub struct IndexerResponsePayload { #[serde(rename = "graphQLResponse")] @@ -86,10 +106,8 @@ impl IndexerClient { pub attestation: Option, pub error: Option, } - let payload = response - .json::() - .await - .map_err(|err| BadResponse(err.to_string()))?; + let payload: IndexerResponsePayload = + serde_json::from_slice(&body).map_err(|err| BadResponse(err.to_string()))?; if let Some(err) = payload.error { return Err(BadResponse(err)); } @@ -208,6 +226,21 @@ fn check_block_error(err: &str) -> Result<(), MissingBlockError> { }) } +/// Read response body with size limit, streaming to avoid unbounded memory allocation. +async fn read_body_limited( + mut response: reqwest::Response, + max_size: usize, +) -> Result, String> { + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + if body.len() + chunk.len() > max_size { + return Err(format!("response exceeds {max_size} byte limit")); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + #[cfg(test)] mod tests { use crate::errors::MissingBlockError; diff --git a/src/main.rs b/src/main.rs index d2596b5b..d7532d37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -109,6 +109,7 @@ async fn main() { let indexer_client = IndexerClient { client: http_client.clone(), + max_response_size: conf.max_indexer_response_size, }; let network_subgraph_client = SubgraphClient { client: indexer_client.clone(), From 4de47261257c221dcfd3ed55bc20c2a1bf58fa21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Thu, 23 Apr 2026 14:44:20 -0300 Subject: [PATCH 2/2] ci: made clippy happy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- src/indexer_client.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/indexer_client.rs b/src/indexer_client.rs index f5f2d84f..715e936f 100644 --- a/src/indexer_client.rs +++ b/src/indexer_client.rs @@ -1,4 +1,7 @@ -use http::{StatusCode, header::{CONTENT_LENGTH, CONTENT_TYPE}}; +use http::{ + StatusCode, + header::{CONTENT_LENGTH, CONTENT_TYPE}, +}; use reqwest::header::AUTHORIZATION; use serde::{Deserialize, Serialize}; use thegraph_core::{ @@ -84,14 +87,13 @@ impl IndexerClient { // Fast-path rejection: check Content-Length header before reading body let max_response_size = self.max_response_size; - if let Some(content_length) = response.headers().get(CONTENT_LENGTH) { - if let Ok(len) = content_length.to_str().unwrap_or("0").parse::() { - if len > max_response_size { - return Err(BadResponse(format!( - "response too large: {len} bytes (max {max_response_size})" - ))); - } - } + if let Some(content_length) = response.headers().get(CONTENT_LENGTH) + && let Ok(len) = content_length.to_str().unwrap_or("0").parse::() + && len > max_response_size + { + return Err(BadResponse(format!( + "response too large: {len} bytes (max {max_response_size})" + ))); } // Read body with size limit to prevent memory exhaustion