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..715e936f 100644 --- a/src/indexer_client.rs +++ b/src/indexer_client.rs @@ -1,4 +1,7 @@ -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 +34,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 +85,22 @@ 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) + && 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 + let body = read_body_limited(response, max_response_size) + .await + .map_err(BadResponse)?; + #[derive(Debug, Deserialize)] pub struct IndexerResponsePayload { #[serde(rename = "graphQLResponse")] @@ -86,10 +108,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 +228,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(),