Skip to content

Commit 83ebfe0

Browse files
fix: address remaining PR review comments
- Pepper: implement custom Debug that redacts secret bytes instead of deriving Debug which would leak pepper material in logs/panics - Error body OOM: use read_response_bounded() for error paths in fullnode.rs (view_bcs and handle_response_static) instead of unbounded response.text().await - Codegen sanitization: apply sanitize_abi_string() to all remaining ABI-derived values (header, MODULE_ADDRESS/MODULE_NAME constants, function_id format strings, event type constants, is_module_event) - URL redaction: fix over-redaction by only checking for '?' within the URL token itself, not anywhere in the message - Tests: add 5 unit tests for read_response_bounded() covering normal, oversized Content-Length, oversized body, exact limit, and empty - SECURITY_AUDIT.md: update report to reflect streaming body reads Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent fd36f1d commit 83ebfe0

File tree

6 files changed

+140
-22
lines changed

6 files changed

+140
-22
lines changed

SECURITY_AUDIT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
**Date:** 2026-02-09
44
**Scope:** Full SDK (`aptos-sdk` v0.3.0, `aptos-sdk-macros` v0.1.0)
55
**Method:** 3-pass audit (automated surface scan, deep manual review, design-level assessment)
6-
**Status:** All findings remediated (21 of 22 fixed; F-21 deferred as large effort)
6+
**Status:** All findings remediated (21 of 22 fixed; F-21 deferred as large effort). Response body reads now use incremental streaming with size limits (`read_response_bounded`) to prevent OOM from chunked transfer-encoding.
77

88
---
99

@@ -353,7 +353,7 @@ The SDK operates with the following trust boundaries:
353353

354354
### 3b. Missing Hardening
355355

356-
1. **Response body streaming** -- All response reads load full bodies into memory. No streaming with incremental size checks. (Addresses F-02, F-03, F-17)
356+
1. **Response body streaming** -- All response reads now use `read_response_bounded()` which pre-checks `Content-Length` and reads incrementally via `response.chunk()`, aborting early if the size limit is exceeded. Error body reads are also bounded. (Addresses F-02, F-03, F-17)
357357
2. **Constant-time operations** -- Signature verification delegates to underlying crates (ed25519-dalek, k256, p256) which use constant-time comparison. The SDK itself does not perform any custom constant-time operations, which is correct.
358358
3. **Fuzz testing** -- Infrastructure exists but is unused (F-21).
359359
4. **Side-channel resistance** -- Signing operations use library implementations with side-channel resistance. Non-security-critical operations (address parsing, ABI processing) are not constant-time, which is acceptable.

crates/aptos-sdk/src/account/keyless.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,15 @@ impl OidcProvider {
176176
/// The pepper is secret material used to derive keyless account addresses.
177177
/// It is automatically zeroized when dropped to prevent key material from
178178
/// lingering in memory.
179-
#[derive(Clone, Debug, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
179+
#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)]
180180
pub struct Pepper(Vec<u8>);
181181

182+
impl std::fmt::Debug for Pepper {
183+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184+
write!(f, "Pepper(REDACTED)")
185+
}
186+
}
187+
182188
impl Pepper {
183189
/// Creates a new pepper from raw bytes.
184190
pub fn new(bytes: Vec<u8>) -> Self {

crates/aptos-sdk/src/api/fullnode.rs

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -556,13 +556,18 @@ impl FullnodeClient {
556556
// Check for errors before reading body
557557
let status = response.status();
558558
if !status.is_success() {
559-
// SECURITY: Truncate error body to prevent storing excessively
560-
// large error messages from malicious servers
561-
let error_text =
562-
Self::truncate_error_body(response.text().await.unwrap_or_default());
559+
// SECURITY: Bound error body reads to prevent OOM from
560+
// malicious servers sending huge error responses.
561+
let error_bytes =
562+
crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
563+
.await
564+
.ok();
565+
let error_text = error_bytes
566+
.and_then(|b| String::from_utf8(b).ok())
567+
.unwrap_or_default();
563568
return Err(AptosError::Api {
564569
status_code: status.as_u16(),
565-
message: error_text,
570+
message: Self::truncate_error_body(error_text),
566571
error_code: None,
567572
vm_error_code: None,
568573
});
@@ -790,9 +795,15 @@ impl FullnodeClient {
790795
// This allows callers to respect the server's rate limiting
791796
Err(AptosError::RateLimited { retry_after_secs })
792797
} else {
793-
// SECURITY: Truncate error body to prevent storing excessively
794-
// large error messages from malicious servers
795-
let error_text = Self::truncate_error_body(response.text().await.unwrap_or_default());
798+
// SECURITY: Bound error body reads to prevent OOM from malicious
799+
// servers sending huge error responses (including chunked encoding).
800+
let error_bytes = crate::config::read_response_bounded(response, MAX_ERROR_BODY_SIZE)
801+
.await
802+
.ok();
803+
let error_text = error_bytes
804+
.and_then(|b| String::from_utf8(b).ok())
805+
.unwrap_or_default();
806+
let error_text = Self::truncate_error_body(error_text);
796807
let body: serde_json::Value = serde_json::from_str(&error_text).unwrap_or_default();
797808
let message = body
798809
.get("message")

crates/aptos-sdk/src/codegen/generator.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,10 @@ impl<'a> ModuleGenerator<'a> {
248248
writeln!(
249249
output,
250250
" pub const {}: &str = \"{}::{}::{}\";",
251-
const_name, self.abi.address, self.abi.name, struct_def.name
251+
const_name,
252+
sanitize_abi_string(&self.abi.address),
253+
sanitize_abi_string(&self.abi.name),
254+
sanitize_abi_string(&struct_def.name)
252255
)?;
253256
}
254257
writeln!(output, "}}")?;
@@ -299,7 +302,8 @@ impl<'a> ModuleGenerator<'a> {
299302
writeln!(
300303
output,
301304
" event_type.starts_with(\"{}::{}::\")",
302-
self.abi.address, self.abi.name
305+
sanitize_abi_string(&self.abi.address),
306+
sanitize_abi_string(&self.abi.name)
303307
)?;
304308
writeln!(output, "}}")?;
305309
writeln!(output)
@@ -310,7 +314,8 @@ impl<'a> ModuleGenerator<'a> {
310314
writeln!(
311315
output,
312316
"//! Generated Rust bindings for `{}::{}`.",
313-
self.abi.address, self.abi.name
317+
sanitize_abi_string(&self.abi.address),
318+
sanitize_abi_string(&self.abi.name)
314319
)?;
315320
writeln!(output, "//!")?;
316321
writeln!(
@@ -347,14 +352,14 @@ impl<'a> ModuleGenerator<'a> {
347352
writeln!(
348353
output,
349354
"pub const MODULE_ADDRESS: &str = \"{}\";",
350-
self.abi.address
355+
sanitize_abi_string(&self.abi.address)
351356
)?;
352357
writeln!(output)?;
353358
writeln!(output, "/// The module name.")?;
354359
writeln!(
355360
output,
356361
"pub const MODULE_NAME: &str = \"{}\";",
357-
self.abi.name
362+
sanitize_abi_string(&self.abi.name)
358363
)?;
359364
writeln!(output)
360365
}
@@ -569,11 +574,14 @@ impl<'a> ModuleGenerator<'a> {
569574
writeln!(output, ") -> AptosResult<TransactionPayload> {{")?;
570575

571576
// Function body
577+
// SECURITY: Sanitize ABI-derived values embedded in string literals
572578
writeln!(output, " let function_id = format!(")?;
573579
writeln!(
574580
output,
575581
" \"{}::{}::{}\",",
576-
self.abi.address, self.abi.name, func.name
582+
sanitize_abi_string(&self.abi.address),
583+
sanitize_abi_string(&self.abi.name),
584+
sanitize_abi_string(&func.name)
577585
)?;
578586
writeln!(output, " );")?;
579587
writeln!(output)?;
@@ -774,11 +782,14 @@ impl<'a> ModuleGenerator<'a> {
774782
writeln!(output, ") -> AptosResult<Vec<serde_json::Value>> {{")?;
775783

776784
// Function body
785+
// SECURITY: Sanitize ABI-derived values embedded in string literals
777786
writeln!(output, " let function_id = format!(")?;
778787
writeln!(
779788
output,
780789
" \"{}::{}::{}\",",
781-
self.abi.address, self.abi.name, func.name
790+
sanitize_abi_string(&self.abi.address),
791+
sanitize_abi_string(&self.abi.name),
792+
sanitize_abi_string(&func.name)
782793
)?;
783794
writeln!(output, " );")?;
784795
writeln!(output)?;

crates/aptos-sdk/src/config.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,4 +876,83 @@ mod tests {
876876
assert!(config.pool_config().max_idle_total > 0);
877877
assert_eq!(config.chain_id().id(), 2);
878878
}
879+
880+
#[tokio::test]
881+
async fn test_read_response_bounded_normal() {
882+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
883+
let server = MockServer::start().await;
884+
Mock::given(method("GET"))
885+
.respond_with(ResponseTemplate::new(200).set_body_string("hello world"))
886+
.mount(&server)
887+
.await;
888+
889+
let response = reqwest::get(server.uri()).await.unwrap();
890+
let body = read_response_bounded(response, 1024).await.unwrap();
891+
assert_eq!(body, b"hello world");
892+
}
893+
894+
#[tokio::test]
895+
async fn test_read_response_bounded_rejects_oversized_content_length() {
896+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
897+
let server = MockServer::start().await;
898+
// Send a body whose accurate Content-Length exceeds the limit.
899+
// The function should reject based on Content-Length pre-check
900+
// before streaming the full body.
901+
let body = "x".repeat(200);
902+
Mock::given(method("GET"))
903+
.respond_with(ResponseTemplate::new(200).set_body_string(body))
904+
.mount(&server)
905+
.await;
906+
907+
let response = reqwest::get(server.uri()).await.unwrap();
908+
// Limit is 100 but body is 200 -- should be rejected via Content-Length pre-check
909+
let result = read_response_bounded(response, 100).await;
910+
assert!(result.is_err());
911+
let err = result.unwrap_err().to_string();
912+
assert!(err.contains("response too large"));
913+
}
914+
915+
#[tokio::test]
916+
async fn test_read_response_bounded_rejects_oversized_body() {
917+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
918+
let server = MockServer::start().await;
919+
let large_body = "x".repeat(500);
920+
Mock::given(method("GET"))
921+
.respond_with(ResponseTemplate::new(200).set_body_string(large_body))
922+
.mount(&server)
923+
.await;
924+
925+
let response = reqwest::get(server.uri()).await.unwrap();
926+
let result = read_response_bounded(response, 100).await;
927+
assert!(result.is_err());
928+
}
929+
930+
#[tokio::test]
931+
async fn test_read_response_bounded_exact_limit() {
932+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
933+
let server = MockServer::start().await;
934+
let body = "x".repeat(100);
935+
Mock::given(method("GET"))
936+
.respond_with(ResponseTemplate::new(200).set_body_string(body.clone()))
937+
.mount(&server)
938+
.await;
939+
940+
let response = reqwest::get(server.uri()).await.unwrap();
941+
let result = read_response_bounded(response, 100).await.unwrap();
942+
assert_eq!(result.len(), 100);
943+
}
944+
945+
#[tokio::test]
946+
async fn test_read_response_bounded_empty() {
947+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
948+
let server = MockServer::start().await;
949+
Mock::given(method("GET"))
950+
.respond_with(ResponseTemplate::new(200))
951+
.mount(&server)
952+
.await;
953+
954+
let response = reqwest::get(server.uri()).await.unwrap();
955+
let result = read_response_bounded(response, 1024).await.unwrap();
956+
assert!(result.is_empty());
957+
}
879958
}

crates/aptos-sdk/src/error.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,21 @@ impl AptosError {
301301
// SECURITY: Redact URLs with query parameters, which may contain API keys
302302
// or other credentials not caught by keyword patterns above.
303303
// e.g., reqwest errors include the request URL.
304-
if lower.contains("http://") || lower.contains("https://") {
305-
// Check if the URL has a query string (contains '?' after the scheme)
306-
if lower.contains('?') {
307-
return "[REDACTED: message contained URL with query parameters]".into();
304+
// Only redact when '?' appears within a URL token (after the scheme),
305+
// not just anywhere in the message.
306+
for scheme in ["http://", "https://"] {
307+
if let Some(scheme_pos) = lower.find(scheme) {
308+
// Look for '?' after the scheme, within the URL token
309+
// (URLs end at whitespace or common delimiters)
310+
let url_start = scheme_pos;
311+
let url_rest = &lower[url_start..];
312+
let url_end = url_rest
313+
.find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == '\'')
314+
.unwrap_or(url_rest.len());
315+
let url_token = &url_rest[..url_end];
316+
if url_token.contains('?') {
317+
return "[REDACTED: message contained URL with query parameters]".into();
318+
}
308319
}
309320
}
310321

0 commit comments

Comments
 (0)