diff --git a/api/src/tests/transactions_test.rs b/api/src/tests/transactions_test.rs index 829c863227f..4ec25acd52f 100644 --- a/api/src/tests/transactions_test.rs +++ b/api/src/tests/transactions_test.rs @@ -1271,6 +1271,97 @@ async fn test_submit_transaction_rejects_invalid_json() { context.check_golden_output(resp); } +/// Oversized JSON batches must fail on the count cap, even when every item +/// would also fail `VerifyInput` (expired timestamp). That shows verify() +/// never walks the array. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_submit_batch_rejects_oversized_json_before_verify() { + let mut node_config = NodeConfig::default(); + node_config.api.max_submit_transaction_batch_size = 1; + let context = new_test_context_with_config(current_function_name!(), node_config); + + let expired = parseable_expired_json_submit_request(); + let resp = context + .expect_status_code(400) + .post("/transactions/batch", json!([expired.clone(), expired])) + .await; + + assert_eq!(resp["error_code"], "invalid_input"); + let message = resp["message"].as_str().expect("error message"); + assert!( + message.contains("Submitted too many transactions: 2"), + "expected size-cap error, got {message}" + ); + assert!( + message.contains("limit is 1"), + "expected configured limit in error, got {message}" + ); + assert!( + !message.to_ascii_lowercase().contains("expiration"), + "size cap must run before verify(); got {message}" + ); +} + +/// A JSON batch at the configured limit is still verified item-by-item. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_submit_batch_json_at_limit_still_verifies_items() { + let mut node_config = NodeConfig::default(); + node_config.api.max_submit_transaction_batch_size = 2; + let context = new_test_context_with_config(current_function_name!(), node_config); + + let expired = parseable_expired_json_submit_request(); + let resp = context + .expect_status_code(400) + .post("/transactions/batch", json!([expired.clone(), expired])) + .await; + + assert_eq!(resp["error_code"], "invalid_input"); + let message = resp["message"].as_str().expect("error message"); + assert!( + !message.contains("Submitted too many transactions"), + "batch of size == limit must not trip the cap; got {message}" + ); + assert!( + message.to_ascii_lowercase().contains("expiration") + || message.to_ascii_lowercase().contains("past"), + "expected verify() to reject expired items; got {message}" + ); +} + +/// BCS batches cannot be counted until they are decoded, so the same cap +/// still applies after `get_signed_transactions_batch`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_submit_batch_rejects_oversized_bcs_after_decode() { + let mut node_config = NodeConfig::default(); + node_config.api.max_submit_transaction_batch_size = 1; + let mut context = new_test_context_with_config(current_function_name!(), node_config); + + let mut root_account = context.root_account().await; + let first = context.gen_account(); + let second = context.gen_account(); + let txns = vec![ + context.create_user_account_by(&mut root_account, &first), + context.create_user_account_by(&mut root_account, &second), + ]; + let body = bcs::to_bytes(&txns).unwrap(); + + let resp = context + .expect_status_code(400) + .post_bcs_txn("/transactions/batch", body) + .await; + + assert_eq!(resp["error_code"], "invalid_input"); + let message = resp["message"].as_str().expect("error message"); + assert!( + message.contains("Submitted too many transactions: 2"), + "expected size-cap error, got {message}" + ); + assert!( + message.contains("limit is 1"), + "expected configured limit in error, got {message}" + ); +} + #[ignore] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_create_signing_message_rejects_payload_too_large_json_body() { @@ -1731,6 +1822,29 @@ fn gen_string(len: u64) -> String { .collect() } +/// JSON that deserializes as `SubmitTransactionRequest` but fails verify() +/// because the expiration is already in the past. +fn parseable_expired_json_submit_request() -> serde_json::Value { + json!({ + "sender": "0x1", + "sequence_number": "0", + "max_gas_amount": "1", + "gas_unit_price": "0", + "expiration_timestamp_secs": "1", + "payload": { + "type": "entry_function_payload", + "function": "0x1::aptos_account::create_account", + "type_arguments": [], + "arguments": ["0x2"], + }, + "signature": { + "type": "ed25519_signature", + "public_key": "0x0000000000000000000000000000000000000000000000000000000000000001", + "signature": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002", + }, + }) +} + // For use when not using the methods on `TestContext` directly. fn build_path(path: &str) -> String { format!("/v1/transactions{}", path) diff --git a/api/src/transactions.rs b/api/src/transactions.rs index f435cd51415..fd8fe1c8b99 100644 --- a/api/src/transactions.rs +++ b/api/src/transactions.rs @@ -495,6 +495,9 @@ impl TransactionsApi { accept_type: AcceptType, data: SubmitTransactionsBatchPost, ) -> SubmitTransactionsBatchResult { + // JSON already exposes its array length. Cap it before verify() so an + // oversized batch cannot force per-item payload / signature checks. + self.reject_json_batch_if_over_configured_limit(&data)?; data.verify() .context("Submitted transactions invalid") .map_err(|err| { @@ -511,6 +514,8 @@ impl TransactionsApi { .check_api_output_enabled("Submit batch transactions", &accept_type)?; let ledger_info = self.context.get_latest_ledger_info()?; let signed_transactions_batch = self.get_signed_transactions_batch(&ledger_info, data)?; + // BCS (and any other encoding) only reveals the count after decode. + // JSON is already gated above; this remains the backstop after parse. if self.context.max_submit_transaction_batch_size() < signed_transactions_batch.len() { return Err(SubmitTransactionError::bad_request_with_code( format!( @@ -1316,6 +1321,30 @@ impl TransactionsApi { Ok(()) } + /// Refuse a JSON submit-batch whose array is already larger than + /// `max_submit_transaction_batch_size`. BCS is skipped here because the + /// transaction count is unknown until the bytes are decoded. + fn reject_json_batch_if_over_configured_limit( + &self, + data: &SubmitTransactionsBatchPost, + ) -> Result<(), SubmitTransactionError> { + let SubmitTransactionsBatchPost::Json(json_batch) = data else { + return Ok(()); + }; + let submitted = json_batch.0.len(); + let limit = self.context.max_submit_transaction_batch_size(); + if submitted > limit { + return Err(SubmitTransactionError::bad_request_with_code_no_info( + format!( + "Submitted too many transactions: {}, while limit is {}", + submitted, limit + ), + AptosErrorCode::InvalidInput, + )); + } + Ok(()) + } + /// Parses a batch of signed transactions fn get_signed_transactions_batch( &self,