@@ -3174,3 +3174,244 @@ TEST_F(TransactionUnitTests, RemoveSignatureFromMultiNodeTransaction)
31743174 // Verify that all internal signatory tracking and protobuf signature maps are completely clear
31753175 EXPECT_EQ (finalRemoval.size (), 0 );
31763176}
3177+
3178+ // -----
3179+ // Security Test: Prevent transaction smuggling attacks (CVE fix)
3180+ // This test verifies that fromBytes() rejects TransactionLists containing entries
3181+ // with different transaction types, which could be used to smuggle a malicious
3182+ // transaction that gets signed but not displayed to the user.
3183+ TEST_F (TransactionUnitTests, FromBytesRejectsTransactionListWithMixedTransactionTypes)
3184+ {
3185+ // Given
3186+ // Entry 1: A benign TransferTransaction (CryptoTransfer)
3187+ proto::TransactionBody benignBody;
3188+ benignBody.set_memo (" Benign transfer" );
3189+ benignBody.set_allocated_cryptotransfer (new proto::CryptoTransferTransactionBody);
3190+
3191+ proto::SignedTransaction signedTx1;
3192+ signedTx1.set_bodybytes (benignBody.SerializeAsString ());
3193+ proto::Transaction tx1;
3194+ tx1.set_signedtransactionbytes (signedTx1.SerializeAsString ());
3195+
3196+ // Entry 2: A different transaction type (AccountCreate) - this simulates
3197+ // an attacker trying to smuggle a different transaction type
3198+ proto::TransactionBody maliciousBody;
3199+ maliciousBody.set_memo (" Hidden transaction" );
3200+ maliciousBody.set_allocated_cryptocreateaccount (new proto::CryptoCreateTransactionBody);
3201+
3202+ proto::SignedTransaction signedTx2;
3203+ signedTx2.set_bodybytes (maliciousBody.SerializeAsString ());
3204+ proto::Transaction tx2;
3205+ tx2.set_signedtransactionbytes (signedTx2.SerializeAsString ());
3206+
3207+ // Create a TransactionList with mixed types
3208+ proto::TransactionList txList;
3209+ *txList.add_transaction_list () = tx1;
3210+ *txList.add_transaction_list () = tx2;
3211+
3212+ // When / Then
3213+ // This should throw because entries have different transaction types
3214+ EXPECT_THROW (
3215+ Transaction<TransferTransaction>::fromBytes (internal::Utilities::stringToByteVector (txList.SerializeAsString ())),
3216+ std::invalid_argument);
3217+ }
3218+
3219+ // -----
3220+ // Security Test: Prevent transaction smuggling via multiple transactionIds
3221+ // For non-chunked transaction types, multiple transactionIds are not allowed
3222+ // as they could be used to hide malicious transactions.
3223+ TEST_F (TransactionUnitTests, FromBytesRejectsNonChunkedTransactionWithMultipleTransactionIds)
3224+ {
3225+ // Given
3226+ // Entry 1: TransferTransaction with TransactionId A
3227+ proto::TransactionBody txBody1;
3228+ txBody1.set_memo (" Transaction 1" );
3229+ txBody1.set_allocated_cryptotransfer (new proto::CryptoTransferTransactionBody);
3230+
3231+ proto::TransactionID txId1;
3232+ txId1.mutable_accountid ()->set_accountnum (201 );
3233+ txId1.mutable_transactionvalidstart ()->set_seconds (1700000010 );
3234+ txId1.mutable_transactionvalidstart ()->set_nanos (1 );
3235+ txBody1.set_allocated_transactionid (new proto::TransactionID (txId1));
3236+
3237+ proto::SignedTransaction signedTx1;
3238+ signedTx1.set_bodybytes (txBody1.SerializeAsString ());
3239+ proto::Transaction tx1;
3240+ tx1.set_signedtransactionbytes (signedTx1.SerializeAsString ());
3241+
3242+ // Entry 2: Same type but different TransactionId (different timestamp)
3243+ // This simulates an attacker hiding a second transaction
3244+ proto::TransactionBody txBody2;
3245+ txBody2.set_memo (" Transaction 1" ); // Same memo to look identical
3246+ txBody2.set_allocated_cryptotransfer (new proto::CryptoTransferTransactionBody);
3247+
3248+ proto::TransactionID txId2;
3249+ txId2.mutable_accountid ()->set_accountnum (201 );
3250+ txId2.mutable_transactionvalidstart ()->set_seconds (1700000011 ); // Different timestamp!
3251+ txId2.mutable_transactionvalidstart ()->set_nanos (1 );
3252+ txBody2.set_allocated_transactionid (new proto::TransactionID (txId2));
3253+
3254+ proto::SignedTransaction signedTx2;
3255+ signedTx2.set_bodybytes (txBody2.SerializeAsString ());
3256+ proto::Transaction tx2;
3257+ tx2.set_signedtransactionbytes (signedTx2.SerializeAsString ());
3258+
3259+ // Create a TransactionList with two different transactionIds
3260+ proto::TransactionList txList;
3261+ *txList.add_transaction_list () = tx1;
3262+ *txList.add_transaction_list () = tx2;
3263+
3264+ // When / Then
3265+ // This should throw because TransferTransaction (non-chunked) cannot have multiple transactionIds
3266+ EXPECT_THROW (
3267+ Transaction<TransferTransaction>::fromBytes (internal::Utilities::stringToByteVector (txList.SerializeAsString ())),
3268+ std::invalid_argument);
3269+ }
3270+
3271+ // -----
3272+ // Security Test: FileAppend (chunked transaction) can have multiple transactionIds
3273+ // but they must all have the same transaction type.
3274+ TEST_F (TransactionUnitTests, FromBytesAllowsChunkedTransactionWithMultipleTransactionIdsOfSameType)
3275+ {
3276+ // Given
3277+ // Entry 1: FileAppend chunk 1
3278+ proto::TransactionBody txBody1;
3279+ txBody1.set_memo (" FileAppend chunk 1" );
3280+ txBody1.set_allocated_fileappend (new proto::FileAppendTransactionBody);
3281+
3282+ proto::TransactionID txId1;
3283+ txId1.mutable_accountid ()->set_accountnum (201 );
3284+ txId1.mutable_transactionvalidstart ()->set_seconds (1700000010 );
3285+ txId1.mutable_transactionvalidstart ()->set_nanos (1 );
3286+ txBody1.set_allocated_transactionid (new proto::TransactionID (txId1));
3287+
3288+ proto::SignedTransaction signedTx1;
3289+ signedTx1.set_bodybytes (txBody1.SerializeAsString ());
3290+ proto::Transaction tx1;
3291+ tx1.set_signedtransactionbytes (signedTx1.SerializeAsString ());
3292+
3293+ // Entry 2: FileAppend chunk 2 (same type, different transactionId - this is valid for chunks)
3294+ proto::TransactionBody txBody2;
3295+ txBody2.set_memo (" FileAppend chunk 2" );
3296+ txBody2.set_allocated_fileappend (new proto::FileAppendTransactionBody);
3297+
3298+ proto::TransactionID txId2;
3299+ txId2.mutable_accountid ()->set_accountnum (201 );
3300+ txId2.mutable_transactionvalidstart ()->set_seconds (1700000011 ); // Different timestamp for chunk 2
3301+ txId2.mutable_transactionvalidstart ()->set_nanos (1 );
3302+ txBody2.set_allocated_transactionid (new proto::TransactionID (txId2));
3303+
3304+ proto::SignedTransaction signedTx2;
3305+ signedTx2.set_bodybytes (txBody2.SerializeAsString ());
3306+ proto::Transaction tx2;
3307+ tx2.set_signedtransactionbytes (signedTx2.SerializeAsString ());
3308+
3309+ // Create a TransactionList
3310+ proto::TransactionList txList;
3311+ *txList.add_transaction_list () = tx1;
3312+ *txList.add_transaction_list () = tx2;
3313+
3314+ // When / Then
3315+ // FileAppend is a chunked transaction type, so multiple transactionIds are allowed
3316+ // as long as they all have the same transaction type
3317+ EXPECT_NO_THROW (
3318+ Transaction<FileAppendTransaction>::fromBytes (internal::Utilities::stringToByteVector (txList.SerializeAsString ())));
3319+ }
3320+
3321+ // -----
3322+ // Security Test: Even FileAppend (chunked) must reject mixed transaction types
3323+ TEST_F (TransactionUnitTests, FromBytesRejectsChunkedTransactionWithMixedTypes)
3324+ {
3325+ // Given
3326+ // Entry 1: FileAppend
3327+ proto::TransactionBody txBody1;
3328+ txBody1.set_memo (" FileAppend" );
3329+ txBody1.set_allocated_fileappend (new proto::FileAppendTransactionBody);
3330+
3331+ proto::TransactionID txId1;
3332+ txId1.mutable_accountid ()->set_accountnum (201 );
3333+ txId1.mutable_transactionvalidstart ()->set_seconds (1700000010 );
3334+ txId1.mutable_transactionvalidstart ()->set_nanos (1 );
3335+ txBody1.set_allocated_transactionid (new proto::TransactionID (txId1));
3336+
3337+ proto::SignedTransaction signedTx1;
3338+ signedTx1.set_bodybytes (txBody1.SerializeAsString ());
3339+ proto::Transaction tx1;
3340+ tx1.set_signedtransactionbytes (signedTx1.SerializeAsString ());
3341+
3342+ // Entry 2: Malicious CryptoTransfer hidden as "chunk 2"
3343+ proto::TransactionBody txBody2;
3344+ txBody2.set_memo (" Hidden drain" );
3345+ txBody2.set_allocated_cryptotransfer (new proto::CryptoTransferTransactionBody); // Different type!
3346+
3347+ proto::TransactionID txId2;
3348+ txId2.mutable_accountid ()->set_accountnum (201 );
3349+ txId2.mutable_transactionvalidstart ()->set_seconds (1700000011 );
3350+ txId2.mutable_transactionvalidstart ()->set_nanos (1 );
3351+ txBody2.set_allocated_transactionid (new proto::TransactionID (txId2));
3352+
3353+ proto::SignedTransaction signedTx2;
3354+ signedTx2.set_bodybytes (txBody2.SerializeAsString ());
3355+ proto::Transaction tx2;
3356+ tx2.set_signedtransactionbytes (signedTx2.SerializeAsString ());
3357+
3358+ // Create a TransactionList
3359+ proto::TransactionList txList;
3360+ *txList.add_transaction_list () = tx1;
3361+ *txList.add_transaction_list () = tx2;
3362+
3363+ // When / Then
3364+ // Even for FileAppend, mixed transaction types must be rejected
3365+ EXPECT_THROW (
3366+ Transaction<FileAppendTransaction>::fromBytes (internal::Utilities::stringToByteVector (txList.SerializeAsString ())),
3367+ std::invalid_argument);
3368+ }
3369+
3370+ // -----
3371+ // Security Test: Directly exercise ChunkedTransaction constructor validation.
3372+ // Build a mixed-type transactions map and ensure chunked constructor rejects it.
3373+ TEST_F (TransactionUnitTests, ChunkedTransactionConstructorRejectsMixedTypes)
3374+ {
3375+ // Given
3376+ // Entry 1: FileAppend (expected chunked type)
3377+ proto::TransactionBody txBody1;
3378+ txBody1.set_memo (" FileAppend chunk 1" );
3379+ txBody1.set_allocated_fileappend (new proto::FileAppendTransactionBody);
3380+
3381+ proto::TransactionID txId1;
3382+ txId1.mutable_accountid ()->set_accountnum (201 );
3383+ txId1.mutable_transactionvalidstart ()->set_seconds (1700000020 );
3384+ txId1.mutable_transactionvalidstart ()->set_nanos (1 );
3385+ txBody1.set_allocated_transactionid (new proto::TransactionID (txId1));
3386+
3387+ proto::SignedTransaction signedTx1;
3388+ signedTx1.set_bodybytes (txBody1.SerializeAsString ());
3389+ proto::Transaction tx1;
3390+ tx1.set_signedtransactionbytes (signedTx1.SerializeAsString ());
3391+
3392+ // Entry 2: Different TransactionId and different type (CryptoTransfer)
3393+ // This simulates a smuggled chunk that should be rejected by ChunkedTransaction.
3394+ proto::TransactionBody txBody2;
3395+ txBody2.set_memo (" Smuggled transfer" );
3396+ txBody2.set_allocated_cryptotransfer (new proto::CryptoTransferTransactionBody);
3397+
3398+ proto::TransactionID txId2;
3399+ txId2.mutable_accountid ()->set_accountnum (201 );
3400+ txId2.mutable_transactionvalidstart ()->set_seconds (1700000021 );
3401+ txId2.mutable_transactionvalidstart ()->set_nanos (1 );
3402+ txBody2.set_allocated_transactionid (new proto::TransactionID (txId2));
3403+
3404+ proto::SignedTransaction signedTx2;
3405+ signedTx2.set_bodybytes (txBody2.SerializeAsString ());
3406+ proto::Transaction tx2;
3407+ tx2.set_signedtransactionbytes (signedTx2.SerializeAsString ());
3408+
3409+ std::map<TransactionId, std::map<AccountId, proto::Transaction>> transactions;
3410+ const auto txIdKey1 = TransactionId::withValidStart (AccountId (201 ), std::chrono::system_clock::from_time_t (1700000020 ));
3411+ const auto txIdKey2 = TransactionId::withValidStart (AccountId (201 ), std::chrono::system_clock::from_time_t (1700000021 ));
3412+ transactions[txIdKey1][AccountId (3 )] = tx1;
3413+ transactions[txIdKey2][AccountId (3 )] = tx2;
3414+
3415+ // When / Then
3416+ EXPECT_THROW (FileAppendTransaction{ transactions }, std::invalid_argument);
3417+ }
0 commit comments