From 73f52cdcb1da0675133358b004a92af3661744f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 10 Jul 2026 11:21:10 -0400 Subject: [PATCH] fix(sync): bound response reads to the snapshot wire limit --- crates/ely_sync_client/src/client.rs | 25 +++++++++-- crates/ely_sync_client/src/client/session.rs | 2 +- crates/ely_sync_client/src/client_tests.rs | 46 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crates/ely_sync_client/src/client.rs b/crates/ely_sync_client/src/client.rs index 41453f8..29afd5f 100644 --- a/crates/ely_sync_client/src/client.rs +++ b/crates/ely_sync_client/src/client.rs @@ -1,4 +1,7 @@ -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::{ + io::{self, Read}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use serde::de::DeserializeOwned; use ureq::{Agent, AgentBuilder}; @@ -13,7 +16,7 @@ use crate::{ }, device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest}, error::SyncClientError, - snapshot::{SnapshotDownload, SnapshotUploadRequest}, + snapshot::{MAX_SNAPSHOT_BYTES, SnapshotDownload, SnapshotUploadRequest}, vault::SyncVaultDocument, vault_bootstrap::SyncVaultBootstrapRequest, }; @@ -421,6 +424,20 @@ pub struct SyncDeviceStatusDocument { pub current_device_approved: bool, } +/// A snapshot download legally carries `MAX_SNAPSHOT_BYTES` of payload as +/// base64 (4/3 expansion) plus its JSON envelope, which exceeds ureq's +/// 10 MiB `into_string` cap. Anything above this bound fails closed. +const MAX_RESPONSE_BODY_BYTES: usize = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 + 64 * 1024; + +fn read_response_body(response: ureq::Response) -> io::Result { + let mut body = String::new(); + response.into_reader().take(MAX_RESPONSE_BODY_BYTES as u64 + 1).read_to_string(&mut body)?; + if body.len() > MAX_RESPONSE_BODY_BYTES { + return Err(io::Error::other("response body exceeds the sync wire limit")); + } + Ok(body) +} + fn read_json_response( endpoint: &str, response: Result, @@ -428,7 +445,7 @@ fn read_json_response( match response { Ok(ok) => read_json_from_response(endpoint, ok), Err(ureq::Error::Status(status, raw)) => { - let body = raw.into_string().unwrap_or_default(); + let body = read_response_body(raw).unwrap_or_default(); if session::response_ends_session(status, &body) { return Err(SyncClientError::SessionEnded); } @@ -445,7 +462,7 @@ fn read_json_from_response( response: ureq::Response, ) -> Result { let status = response.status(); - let body = response.into_string().map_err(|error| SyncClientError::HttpStatus { + let body = read_response_body(response).map_err(|error| SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body: error.to_string(), diff --git a/crates/ely_sync_client/src/client/session.rs b/crates/ely_sync_client/src/client/session.rs index 355c5ba..7bc6564 100644 --- a/crates/ely_sync_client/src/client/session.rs +++ b/crates/ely_sync_client/src/client/session.rs @@ -44,7 +44,7 @@ impl SyncApiClient { Ok(()) } Err(ureq::Error::Status(status, response)) => { - let body = response.into_string().unwrap_or_default(); + let body = super::read_response_body(response).unwrap_or_default(); if response_ends_session(status, &body) { return Ok(()); } diff --git a/crates/ely_sync_client/src/client_tests.rs b/crates/ely_sync_client/src/client_tests.rs index ffd5f54..af1276a 100644 --- a/crates/ely_sync_client/src/client_tests.rs +++ b/crates/ely_sync_client/src/client_tests.rs @@ -282,6 +282,52 @@ fn read_complete_request(stream: &mut std::net::TcpStream) -> std::io::Result Result<(), Box> { + let data_base64 = "A".repeat(11 * 1024 * 1024); + let (base_url, server) = spawn_snapshot_download_server(data_base64.clone())?; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + let requested = SnapshotHeadRef::new(7, "snapshot-big", "ab".repeat(32))?; + + let SnapshotDownloadResult::Downloaded(download) = client.download_snapshot(&requested)? else { + return Err("snapshot download body was not preserved".into()); + }; + assert_eq!(download.data_base64.len(), data_base64.len()); + join_server(server) +} + +#[test] +fn oversized_response_bodies_fail_closed() -> Result<(), Box> { + let (base_url, server) = spawn_snapshot_download_server("A".repeat(14 * 1024 * 1024))?; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + let requested = SnapshotHeadRef::new(7, "snapshot-big", "ab".repeat(32))?; + + let result = client.download_snapshot(&requested); + assert!(matches!( + &result, + Err(crate::SyncClientError::HttpStatus { body, .. }) if body.contains("sync wire limit") + )); + join_server(server) +} + +fn spawn_snapshot_download_server( + data_base64: String, +) -> Result<(String, TestServer), Box> { + let body = format!( + r#"{{"version":3,"user_id":"user-01","device_id":"device-remote","snapshot":{{"snapshot_id":"snapshot-big","r2_key":"snapshots/user-01","payload_hash":"{hash}","encryption_version":2,"vault_generation":1,"key_id":"{key}","content_hash":"{content}","schema_rev":1,"logical_clock":9,"head_revision":7,"base_head":null,"device_id":"device-remote","size_bytes":256,"created_at":1}},"data_base64":"{data_base64}"}}"#, + hash = "ab".repeat(32), + key = "ef".repeat(32), + content = "12".repeat(32), + ); + spawn_authenticated_server("GET /api/sync/snapshot?snapshot_id=snapshot-big", "200 OK", &body) +} + fn spawn_logout_server( status_line: &'static str, body: &str,