Various performance and security fixes#32
Conversation
There was a problem hiding this comment.
Pull request overview
This PR bundles a set of hardening and efficiency improvements across the Aptos Rust SDK, focusing on safer defaults, reduced allocations/locking, and tighter handling of untrusted inputs (HTTP responses, ABIs, JWT/OIDC metadata).
Changes:
- Fixes/adjusts transaction expiration handling and reduces cloning/locking overhead (Atomic chain id, shared retry config).
- Adds multiple security hardenings: low-S ECDSA enforcement, zeroization of sensitive buffers, URL scheme validation, ABI/path traversal protections, and error-message sanitization.
- Introduces response-size limits and other allocation reductions in API clients and codegen parsing.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/aptos-sdk/src/transaction/sponsored.rs | Fixes default expiration calculation to avoid double-applying the offset. |
| crates/aptos-sdk/src/transaction/batch.rs | Replaces RwLock chain-id caching with AtomicU8 for lower overhead. |
| crates/aptos-sdk/src/retry.rs | Stores retry config in Arc and adds from_shared to avoid clones. |
| crates/aptos-sdk/src/error.rs | Expands sensitive-pattern redaction and adds URL-with-query redaction in sanitized_message(). |
| crates/aptos-sdk/src/crypto/secp256r1.rs | Enforces/validates low‑S signatures (sign/parse/verify) and adds tests. |
| crates/aptos-sdk/src/crypto/secp256k1.rs | Enforces/validates low‑S signatures (sign/parse/verify) and adds tests. |
| crates/aptos-sdk/src/crypto/ed25519.rs | Zeroizes a temporary private-key buffer after constructing the signing key. |
| crates/aptos-sdk/src/config.rs | Makes URL scheme validation public + reduces default max response size to 10MB. |
| crates/aptos-sdk/src/codegen/types.rs | Generates BCS arg encoding with error propagation instead of unwrap(). |
| crates/aptos-sdk/src/codegen/move_parser.rs | Adds a max Move source size limit and reduces doc parsing allocations. |
| crates/aptos-sdk/src/codegen/generator.rs | Adds ABI string sanitization for some emitted docs/attributes. |
| crates/aptos-sdk/src/codegen/build_helper.rs | Validates module names to prevent path traversal and mod.rs injection. |
| crates/aptos-sdk/src/aptos.rs | Uses AtomicU8 for chain-id caching and removes unnecessary payload clones. |
| crates/aptos-sdk/src/api/indexer.rs | Adds URL scheme validation and response-size checks; uses shared retry config. |
| crates/aptos-sdk/src/api/fullnode.rs | Adds error-body truncation and response-size enforcement; uses shared retry config. |
| crates/aptos-sdk/src/api/faucet.rs | Adds URL scheme validation and response-size checks; uses shared retry config. |
| crates/aptos-sdk/src/account/mnemonic.rs | Switches seed derivation to return Result and zeroizes entropy/seed/key material. |
| crates/aptos-sdk/src/account/keyless.rs | Adds HTTPS-only JWKS handling + response-size checks; zeroizes Pepper on drop. |
| crates/aptos-sdk/examples/account_management.rs | Adds clearer “demo only” warning when printing a private key. |
| crates/aptos-sdk-macros/src/lib.rs | Canonical-path check to prevent path traversal in compile-time ABI file reads. |
| crates/aptos-sdk-macros/src/codegen.rs | Validates ABI-derived identifiers before format_ident! to avoid panics/injection. |
| SECURITY_AUDIT.md | Adds a security audit report documenting findings/remediations. |
| CLAUDE.md | Updates the referenced location of behavioral tests. |
Comments suppressed due to low confidence (2)
crates/aptos-sdk/src/config.rs:29
validate_url_schemedocs state that HTTP is allowed for localhost development only, but the implementation currently accepts anyhttp://URL (no host check). Either enforce the localhost-only restriction (e.g., allow onlylocalhost,127.0.0.1,::1) or update the documentation to match the actual behavior.
/// Validates that a URL uses a safe scheme (http or https).
///
/// # Security
///
/// This prevents SSRF attacks via dangerous URL schemes like `file://`, `gopher://`, etc.
/// For production use, HTTPS is strongly recommended. HTTP is allowed for localhost
/// development only.
/// # Errors
///
/// Returns [`AptosError::Config`] if the URL scheme is not `http` or `https`.
pub fn validate_url_scheme(url: &Url) -> AptosResult<()> {
match url.scheme() {
"https" => Ok(()),
"http" => {
// HTTP is allowed but only recommended for localhost development
// Log a warning in the future if we add logging
Ok(())
}
crates/aptos-sdk/src/error.rs:317
sanitize_stringtruncates with&cleaned[..MAX_ERROR_MESSAGE_LENGTH], which can panic ifMAX_ERROR_MESSAGE_LENGTHlands in the middle of a UTF-8 codepoint. Please truncate at a validis_char_boundary(similar totruncate_error_body) or usechars().take(...)to guarantee this never panics while sanitizing/logging errors.
// Truncate if too long
if cleaned.len() > MAX_ERROR_MESSAGE_LENGTH {
format!(
"{}... [truncated, total length: {}]",
&cleaned[..MAX_ERROR_MESSAGE_LENGTH],
cleaned.len()
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Sanitizes an ABI-derived string for safe embedding in generated Rust code. | ||
| /// | ||
| /// # Security | ||
| /// | ||
| /// Prevents code injection via crafted ABI JSON by: | ||
| /// - Replacing newlines (which could escape doc comments and inject code) | ||
| /// - Escaping double quotes (which could break string literals) | ||
| /// - Escaping backslashes (which could create escape sequences) | ||
| fn sanitize_abi_string(s: &str) -> String { | ||
| s.replace('\\', "\\\\") | ||
| .replace('"', "\\\"") | ||
| .replace(['\n', '\r'], " ") | ||
| } |
There was a problem hiding this comment.
sanitize_abi_string is introduced, but several ABI-derived values are still written into generated Rust string literals/comments without escaping (e.g., header //! Generated ..., MODULE_ADDRESS/MODULE_NAME constants, is_module_event prefix, and function_id construction). A crafted ABI containing quotes/backslashes/newlines could still break the generated syntax. Please apply escaping/sanitization consistently anywhere ABI strings are emitted into Rust string literals or comments (not just doc lines/serde attributes).
| #[derive(Clone, Debug, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] | ||
| pub struct Pepper(Vec<u8>); | ||
|
|
There was a problem hiding this comment.
Pepper is documented as secret material, but it derives Debug, which will print the inner bytes and can leak the pepper via logs/panic messages. Please remove Debug from the derive list or implement a custom Debug that redacts the value (similar to the private key types).
| #[derive(Clone, Debug, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] | |
| pub struct Pepper(Vec<u8>); | |
| #[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] | |
| pub struct Pepper(Vec<u8>); | |
| impl fmt::Debug for Pepper { | |
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| write!(f, "Pepper(REDACTED)") | |
| } | |
| } |
| let info = response.into_inner(); | ||
|
|
||
| // Update chain_id if it was unknown (custom network) | ||
| if self.chain_id.read().expect("chain_id lock poisoned").id() == 0 && info.chain_id > 0 { | ||
| *self.chain_id.write().expect("chain_id lock poisoned") = ChainId::new(info.chain_id); | ||
| if self.chain_id.load(Ordering::Relaxed) == 0 && info.chain_id > 0 { | ||
| self.chain_id.store(info.chain_id, Ordering::Relaxed); | ||
| } |
There was a problem hiding this comment.
The ledger_info rustdoc (just above this block) still refers to panicking on a poisoned chain_id lock, but chain_id is now stored in an AtomicU8 (no lock). Please remove/update that # Panics section to keep the public API docs accurate.
| pub max_response_size: usize, | ||
| } | ||
|
|
||
| /// Default maximum response size: 100 MB | ||
| const DEFAULT_MAX_RESPONSE_SIZE: usize = 100 * 1024 * 1024; | ||
| /// Default maximum response size: 10 MB | ||
| /// |
There was a problem hiding this comment.
The max_response_size field docs still mention a 100 MB default (see the comment immediately above this field), but the default constant was changed to 10 MB below. Please update the field-level documentation to reflect the new default to avoid confusing users.
| /// For unknown issuers, the JWKS URL is constructed as `{issuer}/.well-known/jwks.json`. | ||
| /// Only HTTPS issuers are accepted to prevent SSRF via `http://`, `file://`, or other | ||
| /// dangerous URL schemes. Callers controlling issuer input should additionally validate | ||
| /// the host (e.g., block private IP ranges) if SSRF is a concern. | ||
| pub fn from_issuer(issuer: &str) -> Self { | ||
| match issuer { | ||
| "https://accounts.google.com" => OidcProvider::Google, | ||
| "https://appleid.apple.com" => OidcProvider::Apple, | ||
| "https://login.microsoftonline.com/common/v2.0" => OidcProvider::Microsoft, | ||
| _ => OidcProvider::Custom { | ||
| issuer: issuer.to_string(), | ||
| jwks_url: format!("{issuer}/.well-known/jwks.json"), | ||
| }, | ||
| _ => { | ||
| // SECURITY: Only accept HTTPS issuers to prevent SSRF attacks. | ||
| // A malicious JWT could set `iss` to an internal URL (e.g., | ||
| // http://169.254.169.254/) causing the SDK to make requests to | ||
| // attacker-chosen endpoints when fetching JWKS. | ||
| let jwks_url = if issuer.starts_with("https://") { | ||
| format!("{issuer}/.well-known/jwks.json") | ||
| } else { | ||
| // Non-HTTPS issuers get an invalid JWKS URL that will fail | ||
| // at fetch time with a clear error rather than making requests | ||
| // to potentially dangerous endpoints. | ||
| String::new() | ||
| }; |
There was a problem hiding this comment.
from_issuer docs say “Only HTTPS issuers are accepted”, but the implementation still returns OidcProvider::Custom for non-HTTPS issuers (with an empty jwks_url) and only fails later when fetching/parsing. Please either (a) adjust the docs to clarify that non-HTTPS issuers result in a provider that will error at JWKS fetch time, or (b) change the API to return a Result (if acceptable) so rejection happens at construction time.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 9 comments.
Comments suppressed due to low confidence (1)
crates/aptos-sdk/src/config.rs:28
- The docs claim "HTTP is allowed for localhost development only", but
validate_url_schemecurrently allowshttpfor any host. Since this function is nowpuband used as a security control, the documentation should either match the behavior (scheme-only validation) or the function should enforce the localhost-only constraint (host check) if that’s the intent.
/// This prevents SSRF attacks via dangerous URL schemes like `file://`, `gopher://`, etc.
/// For production use, HTTPS is strongly recommended. HTTP is allowed for localhost
/// development only.
/// # Errors
///
/// Returns [`AptosError::Config`] if the URL scheme is not `http` or `https`.
pub fn validate_url_scheme(url: &Url) -> AptosResult<()> {
match url.scheme() {
"https" => Ok(()),
"http" => {
// HTTP is allowed but only recommended for localhost development
// Log a warning in the future if we add logging
Ok(())
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
crates/aptos-sdk/src/api/fullnode.rs
Outdated
| // SECURITY: Truncate error body to prevent storing excessively | ||
| // large error messages from malicious servers | ||
| let error_text = Self::truncate_error_body(response.text().await.unwrap_or_default()); |
There was a problem hiding this comment.
In the non-success path, response.text().await buffers the full error body before truncation and JSON parsing. This still leaves an OOM/memory-exhaustion vector on large error bodies. Consider reading the error body with a hard cap (streaming with a limit, or at least rejecting large Content-Length values before calling text()).
| // SECURITY: Truncate error body to prevent storing excessively | |
| // large error messages from malicious servers | |
| let error_text = Self::truncate_error_body(response.text().await.unwrap_or_default()); | |
| // SECURITY: Avoid unbounded buffering of large error bodies. | |
| // First, use Content-Length (if present) to reject excessively | |
| // large error responses before calling `text()`, then truncate. | |
| const MAX_ERROR_BODY_SIZE: u64 = 64 * 1024; // 64 KiB cap for error bodies | |
| let error_text = if let Some(len) = response.content_length() { | |
| if len > MAX_ERROR_BODY_SIZE { | |
| // Do not read the body at all if it is declared too large. | |
| "error body too large to display".to_string() | |
| } else { | |
| let text = response.text().await.unwrap_or_default(); | |
| Self::truncate_error_body(text) | |
| } | |
| } else { | |
| // Unknown length (e.g., chunked): read then truncate as before. | |
| let text = response.text().await.unwrap_or_default(); | |
| Self::truncate_error_body(text) | |
| }; |
crates/aptos-sdk/src/error.rs
Outdated
| if lower.contains("http://") || lower.contains("https://") { | ||
| // Check if the URL has a query string (contains '?' after the scheme) | ||
| if lower.contains('?') { | ||
| return "[REDACTED: message contained URL with query parameters]".into(); | ||
| } |
There was a problem hiding this comment.
The URL-query redaction logic checks for any '?' anywhere in the message whenever it contains "http://" or "https://". This can over-redact unrelated messages that happen to include a question mark. Consider extracting actual URLs and checking whether they contain a query component (e.g., parse substrings as Url and inspect query()), or at least ensure the '?' occurs within a URL token.
| } | ||
|
|
||
| // Truncate if too long | ||
| if cleaned.len() > MAX_ERROR_MESSAGE_LENGTH { | ||
| format!( |
There was a problem hiding this comment.
The truncation path slices &cleaned[..MAX_ERROR_MESSAGE_LENGTH] at a fixed byte offset, which can panic if the string contains multi-byte UTF-8 characters (not a char boundary). Since sanitized_message() is intended to be safe for logging, it should not be able to panic. Consider truncating on a valid UTF-8 boundary (similar to truncate_error_body) or using char_indices() to compute the cutoff safely.
SECURITY_AUDIT.md
Outdated
| **Status:** All findings remediated (21 of 22 fixed; F-21 deferred as large effort) | ||
|
|
There was a problem hiding this comment.
The report states "All findings remediated" / "21 of 22 fixed", but some of the size-limit remediations still buffer full response bodies into memory (e.g., using response.bytes() / response.text() and checking/truncating afterward). Consider updating this status language (or the remediation details) so the report doesn’t overstate the current protection against OOM on large/chunked responses.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Documentation | ||
| // SECURITY: Sanitize ABI-derived strings to prevent code injection via newlines | ||
| writeln!( | ||
| output, | ||
| "/// Move struct: `{}::{}`", |
There was a problem hiding this comment.
Sanitization is only applied to some ABI-derived strings. Other generated-code emission sites still interpolate self.abi.name / self.abi.address (and function names) into doc comments and string literals without escaping (e.g., header/module constants/function-id construction), which can still enable code injection or broken output with crafted ABI JSON. Consider applying sanitize_abi_string() (or stricter validation) consistently anywhere ABI data is written into generated Rust source.
| pub async fn read_response_bounded( | ||
| mut response: reqwest::Response, | ||
| max_size: usize, | ||
| ) -> AptosResult<Vec<u8>> { |
There was a problem hiding this comment.
read_response_bounded() is a new security-critical primitive (size limiting, chunked handling) but there are no unit tests covering its behavior. Since this file already has tests, consider adding tests for: (1) rejecting oversized Content-Length, and (2) rejecting responses that exceed the limit via chunked streaming.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| //! - **Signing** always produces low-S signatures (the `p256` crate normalizes | ||
| //! by default). |
There was a problem hiding this comment.
The module-level docs say “Signing always produces low-S signatures (the p256 crate normalizes by default)”, but the implementation immediately below states “The p256 crate does not guarantee low-S output from signing” and performs explicit normalization. Please align the documentation with the actual behavior/guarantees (either drop the “by default” claim or clarify that the SDK normalizes regardless of crate behavior).
| //! - **Signing** always produces low-S signatures (the `p256` crate normalizes | |
| //! by default). | |
| //! - **Signing** always produces low-S signatures (normalized by this SDK, | |
| //! independent of the `p256` crate's default behavior). |
| /// Derives the seed from this mnemonic. | ||
| /// | ||
| /// Uses an empty passphrase by default. | ||
| pub fn to_seed(&self) -> [u8; 64] { | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns an error if the mnemonic cannot be re-parsed (should not happen | ||
| /// since the phrase was validated during construction). | ||
| pub fn to_seed(&self) -> AptosResult<[u8; 64]> { | ||
| self.to_seed_with_passphrase("") | ||
| } |
There was a problem hiding this comment.
Mnemonic::to_seed changed from returning [u8; 64] to AptosResult<[u8; 64]>, which is a breaking public API change. If this is intentional, it should be called out in release notes / changelog and any docs/examples should be updated to handle the Result (or consider adding a new fallible method and keeping the infallible one for backward compatibility).
crates/aptos-sdk/src/config.rs
Outdated
| // This protects against chunked transfer-encoding that bypasses Content-Length. | ||
| let mut body = Vec::with_capacity(std::cmp::min(max_size, 1024 * 1024)); | ||
| while let Some(chunk) = response.chunk().await? { | ||
| if body.len() + chunk.len() > max_size { |
There was a problem hiding this comment.
read_response_bounded uses body.len() + chunk.len() for the size check; in release builds this addition can wrap on usize overflow and potentially bypass the limit. Use checked_add/saturating_add (or compare chunk.len() > max_size - body.len()) to make the bound robust for all max_size values.
| if body.len() + chunk.len() > max_size { | |
| let new_len = match body.len().checked_add(chunk.len()) { | |
| Some(len) => len, | |
| None => { | |
| // Overflow in length calculation implies the response is effectively too large. | |
| return Err(AptosError::Api { | |
| status_code: response.status().as_u16(), | |
| message: format!( | |
| "response too large: exceeded limit of {max_size} bytes during streaming" | |
| ), | |
| error_code: Some("RESPONSE_TOO_LARGE".into()), | |
| vm_error_code: None, | |
| }); | |
| } | |
| }; | |
| if new_len > max_size { |
| // Must be a valid Rust identifier: starts with letter or underscore, | ||
| // contains only alphanumeric or underscore characters | ||
| let mut chars = name.chars(); | ||
| let first = chars.next().unwrap(); // safe: name is non-empty | ||
| if !first.is_ascii_alphabetic() && first != '_' { | ||
| return Err(AptosError::Config(format!( | ||
| "invalid module name '{name}': must start with a letter or underscore" | ||
| ))); | ||
| } | ||
|
|
||
| if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') { | ||
| return Err(AptosError::Config(format!( |
There was a problem hiding this comment.
validate_module_name is documented as enforcing a “valid Rust identifier”, but it doesn’t reject Rust keywords (e.g., mod, crate, self), which can still produce invalid mod.rs/file output or macro expansions. Consider checking against the Rust keyword set (or using syn::parse_str::<Ident>() / proc_macro2::Ident validation) and either reject or emit r#... raw identifiers.
|
|
||
| /// Safely creates a `format_ident!` from a string, returning a compile error if invalid. | ||
| fn safe_format_ident(name: &str) -> Result<proc_macro2::Ident, TokenStream> { | ||
| if let Err(e) = validate_rust_ident(name) { | ||
| return Err(syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error()); | ||
| } |
There was a problem hiding this comment.
safe_format_ident can still panic on Rust keywords because validate_rust_ident only checks character classes; format_ident!("{}", "fn") (or mod, crate, etc.) will panic inside proc_macro2::Ident::new. Since this helper is explicitly meant to prevent panics on malformed ABI, add a keyword check and either return a compile error or generate a raw identifier (Ident::new_raw / r#...).
| /// Safely creates a `format_ident!` from a string, returning a compile error if invalid. | |
| fn safe_format_ident(name: &str) -> Result<proc_macro2::Ident, TokenStream> { | |
| if let Err(e) = validate_rust_ident(name) { | |
| return Err(syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error()); | |
| } | |
| /// Returns true if `name` is a reserved Rust keyword. | |
| fn is_rust_keyword(name: &str) -> bool { | |
| matches!( | |
| name, | |
| // Strict keywords | |
| "as" | |
| | "break" | |
| | "const" | |
| | "continue" | |
| | "crate" | |
| | "else" | |
| | "enum" | |
| | "extern" | |
| | "false" | |
| | "fn" | |
| | "for" | |
| | "if" | |
| | "impl" | |
| | "in" | |
| | "let" | |
| | "loop" | |
| | "match" | |
| | "mod" | |
| | "move" | |
| | "mut" | |
| | "pub" | |
| | "ref" | |
| | "return" | |
| | "self" | |
| | "Self" | |
| | "static" | |
| | "struct" | |
| | "super" | |
| | "trait" | |
| | "true" | |
| | "type" | |
| | "unsafe" | |
| | "use" | |
| | "where" | |
| | "while" | |
| // 2018+ keywords and common contextual keywords that are rejected by `Ident::new` | |
| | "async" | |
| | "await" | |
| | "dyn" | |
| | "abstract" | |
| | "become" | |
| | "box" | |
| | "do" | |
| | "final" | |
| | "macro" | |
| | "override" | |
| | "priv" | |
| | "try" | |
| | "typeof" | |
| | "unsized" | |
| | "virtual" | |
| | "yield" | |
| ) | |
| } | |
| /// Safely creates a `format_ident!` from a string, returning a compile error if invalid. | |
| fn safe_format_ident(name: &str) -> Result<proc_macro2::Ident, TokenStream> { | |
| if let Err(e) = validate_rust_ident(name) { | |
| return Err(syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error()); | |
| } | |
| // If this is a Rust keyword, construct a raw identifier to avoid panics from `Ident::new`. | |
| if is_rust_keyword(name) { | |
| return Ok(proc_macro2::Ident::new_raw( | |
| name, | |
| proc_macro2::Span::call_site(), | |
| )); | |
| } |
3-pass security audit identified 22 findings (1 critical, 3 high,
10 medium, 5 low, 3 info). This commit fixes 21 of them:
Critical:
- Prevent path traversal via abi.name in codegen build_helper
High:
- Add response size enforcement to view_bcs (was unbounded)
- Fix Content-Length bypass in handle_response_static by reading
bytes first then deserializing (prevents chunked encoding bypass)
- Sanitize ABI-derived strings in codegen generator to prevent
code injection via newlines in doc comments
Medium:
- Validate identifiers before format_ident! in proc macros
- Replace .unwrap() with error propagation in generated BCS code
- Escape serde rename strings from ABI input
- Add validate_url_scheme() to IndexerClient/FaucetClient with_url()
- Expand sensitive error redaction patterns (7 -> 15 patterns)
- Document sanitized_message() security requirement on AptosError
- Zeroize entropy, seed, and chain_code in mnemonic derivation
- Add 10MB input size limit to MoveSourceParser
- Validate module names in generate_mod_file
- Lower default max response size from 100MB to 10MB
Low:
- Replace expect("lock poisoned") with graceful PoisonError recovery
- Truncate error body reads to 8KB MAX_ERROR_BODY_SIZE
- Fix sponsored transaction double-applied expiration offset
- Add path canonicalization check in aptos_contract_file! macro
Info:
- Add [DEMO ONLY] warning to private key print in example
- Fix stale specifications/tests/ reference in CLAUDE.md
Full report in SECURITY_AUDIT.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Key changes: - Replace RwLock<ChainId> with AtomicU8 for lock-free chain ID access. ChainId is a single u8; atomic operations avoid lock acquisition on every transaction build and batch operation. - Add RetryExecutor::from_shared(Arc<RetryConfig>) to avoid cloning the inner RetryConfig (including its HashSet) on every API call. All API clients now pass the Arc directly instead of dereferencing and cloning. - Remove unnecessary payload.clone() in simulate_and_submit and simulate_submit_and_wait -- the payload was consumed by build_transaction and never used again. - Eliminate per-element format!+json! allocation in view_bcs hex encoding by writing hex directly into a pre-sized String buffer. - Fix O(n^2) doc_lines.insert(0, ...) in Move parser by collecting in reverse then calling reverse() once. - Avoid intermediate Vec<String> in GraphQL error message building by writing directly into a single String. Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses findings from a comprehensive second security audit: Crypto: - Zeroize temporary key_bytes buffer in Ed25519PrivateKey::from_bytes to prevent private key material lingering on the stack Keyless/OIDC (SSRF prevention): - Restrict JWKS URLs to HTTPS in OidcProvider::from_issuer to prevent SSRF attacks via malicious JWT iss claims pointing to internal services (e.g., cloud metadata endpoints at 169.254.169.254) - Add explicit URL scheme validation in fetch_jwks before making requests - Add Zeroize+ZeroizeOnDrop to Pepper type to clear secret material from memory on drop Response size limits (OOM prevention): - FaucetClient: read bytes + enforce 1MB limit before JSON deserialization - IndexerClient: read bytes + enforce 10MB limit before JSON deserialization - Keyless JWKS/pepper/prover: enforce 1MB limit before deserialization (previously used response.json() which buffered unbounded) Error handling: - Redact URLs with query parameters in sanitized_message() to prevent leaking API keys or credentials embedded in URLs - Replace expect() in Mnemonic::to_seed_with_passphrase with proper error propagation (to_seed and to_seed_with_passphrase now return AptosResult) Note: Mnemonic::to_seed() return type changed from [u8; 64] to AptosResult<[u8; 64]> - this is a minor breaking change but the error path is unreachable in practice since the phrase is validated on construction. Co-authored-by: Cursor <cursoragent@cursor.com>
Aptos on-chain verification rejects high-S ECDSA signatures for both secp256k1 and secp256r1 to prevent signature malleability. The SDK was previously accepting both forms, which could lead to signatures that pass SDK validation but fail on-chain. - Normalize signatures to low-S at signing time (critical for p256 which does not guarantee low-S output; defense-in-depth for k256) - Reject high-S signatures in from_bytes/from_hex/deserialization - Reject high-S signatures in verify/verify_prehashed - Add tests for high-S rejection and low-S signing invariant Co-authored-by: Cursor <cursoragent@cursor.com>
- Remove doc-link to private const `MAX_SOURCE_SIZE` in move_parser.rs - Fix unresolved doc-links to `Display` and `to_string()` in error.rs Co-authored-by: Cursor <cursoragent@cursor.com>
- Replace response.bytes().await? with streaming read_response_bounded() that pre-checks Content-Length and reads body incrementally via chunk(), aborting early if size limit is exceeded (prevents OOM from chunked transfer encoding) - Fix stale docs: remove #Panics section referencing lock on AtomicU8, update max_response_size default from 100MB to 10MB, clarify validate_url_scheme accepts any HTTP (not just localhost) - Fix from_issuer docs to clarify non-HTTPS issuers fail at fetch time - Fix sanitize_string UTF-8 truncation to use is_char_boundary - Add comment explaining benign TOCTOU race on AtomicU8 chain_id Co-authored-by: Cursor <cursoragent@cursor.com>
Revert to the simpler hex::encode() one-liner. The manual byte-by-byte fmt::Write approach was a micro-optimization that hurts readability; let the compiler and downstream users handle this if needed. Co-authored-by: Cursor <cursoragent@cursor.com>
- 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>
- Fix secp256r1 docs: clarify SDK normalizes low-S, not p256 crate - Use saturating_add in read_response_bounded to prevent overflow - Handle Rust keywords in proc macro safe_format_ident via raw idents - Reject Rust keywords in validate_module_name for safe mod.rs gen
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // SECURITY: Verify the resolved path is under CARGO_MANIFEST_DIR to prevent | ||
| // path traversal attacks (e.g., "../../../../etc/passwd") | ||
| if let (Ok(canonical_manifest), Ok(canonical_file)) = | ||
| (manifest_path.canonicalize(), file_path.canonicalize()) | ||
| && !canonical_file.starts_with(&canonical_manifest) | ||
| { | ||
| return syn::Error::new( | ||
| input.name.span(), | ||
| format!( | ||
| "ABI file path '{}' resolves outside the project directory", | ||
| input.path | ||
| ), | ||
| ) | ||
| .to_compile_error() | ||
| .into(); | ||
| } |
There was a problem hiding this comment.
The path traversal protection has a security gap: if either manifest_path.canonicalize() or file_path.canonicalize() fails (returns Err), the validation is silently skipped and the file read proceeds. An attacker could exploit this by referencing a path that doesn't exist yet but would be created later, or by triggering permission errors that prevent canonicalization.
Consider handling canonicalization failures explicitly:
let canonical_manifest = manifest_path.canonicalize().map_err(|e| {
syn::Error::new(
input.name.span(),
format!("Failed to resolve project directory: {e}")
)
})?;
let canonical_file = file_path.canonicalize().map_err(|e| {
syn::Error::new(
input.name.span(),
format!("Failed to resolve ABI file path: {e}")
)
})?;
if !canonical_file.starts_with(&canonical_manifest) {
return syn::Error::new(...).to_compile_error().into();
}This ensures the validation always runs and provides clear error messages when canonicalization fails.
Canonicalization failures now produce compile errors instead of silently skipping the path validation check.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for scheme in ["http://", "https://"] { | ||
| if let Some(scheme_pos) = lower.find(scheme) { | ||
| // Look for '?' after the scheme, within the URL token | ||
| // (URLs end at whitespace or common delimiters) | ||
| let url_start = scheme_pos; | ||
| let url_rest = &lower[url_start..]; | ||
| let url_end = url_rest | ||
| .find(|c: char| c.is_whitespace() || c == '>' || c == '"' || c == '\'') | ||
| .unwrap_or(url_rest.len()); | ||
| let url_token = &url_rest[..url_end]; | ||
| if url_token.contains('?') { | ||
| return "[REDACTED: message contained URL with query parameters]".into(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The URL query parameter detection uses lower.find(scheme) to find the scheme position, but then uses indices from the lowercased string to check for '?'. This could theoretically cause issues if the lowercasing changes byte positions (though unlikely for ASCII URLs). More importantly, after finding a URL with query parameters, the function returns immediately without checking if there might be multiple URLs in the error message. While this is conservative (redacting the entire message if any URL has query params), it might be overly aggressive. Consider documenting this behavior or checking all URLs before deciding to redact.
Summary
Comprehensive security hardening and performance improvements based on a 3-pass security audit of the SDK. The audit report is included as
SECURITY_AUDIT.md.Security hardening
read_response_bounded()which pre-checksContent-Lengthand reads incrementally via chunked streaming, aborting early if the size limit is exceeded. This prevents OOM from malicious servers using chunked transfer-encoding. Error body reads are also bounded.from_bytes/from_hex), and verification to match aptos-core's on-chain behavior and prevent signature malleability.sanitize_abi_string()before embedding in generated Rust code. Module names are validated against path traversal and Rust keywords.format_ident!calls are guarded withsafe_format_ident()using raw identifiers for keywords.Peppertype usesZeroizeOnDropwith a redactedDebugimpl. Ed25519 private key buffer is zeroized after construction.with_url) validate URL schemes to prevent SSRF viafile://,gopher://, etc.sanitized_message()redacts sensitive patterns (private keys, mnemonics, JWTs, API keys) and URL query parameters. Error body truncation prevents log flooding. UTF-8 boundary-safe truncation.aptos_contract_file!canonicalizes paths and verifies they remain underCARGO_MANIFEST_DIRto prevent path traversal at compile time.Performance improvements
RwLock<u8>withAtomicU8for chain ID caching, eliminating lock contention on every transaction build.RetryConfigstored inArcwithfrom_shared()to avoid cloning on each request.const_hex::encode_prefixedfor hex encoding instead offormat!("0x{}", ...). Removed unnecessary payload clones in transaction building.Bug fixes
.unwrap()→ proper error propagation.MoveSourceParserlacking input size limit.Test plan
cargo test -p aptos-sdk --all-features— 884 tests passcargo test -p aptos-sdk-macros— trybuild UI tests passcargo clippy -p aptos-sdk --all-features -- -D warnings— cleancargo clippy -p aptos-sdk-macros -- -D warnings— cleancargo fmt -- --check— clean