From 829fd9475614a9100914fb362149ee2399e76064 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Mon, 24 Aug 2026 22:11:37 +0100 Subject: [PATCH 1/4] fix(investment_vault): trust decision uses VAA-verified emitter_chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit complete_bridge_transfer's trusted-emitter lookup keyed off transfer.source_chain — a field decoded from the payload bytes themselves, which parse_bridge_payload doesn't independently validate — instead of parsed.emitter_chain, the chain ID Wormhole's guardian network actually cryptographically attests as the VAA's real origin. Using a sender-supplied payload field for a trust decision instead of the VAA-verified envelope metadata is exactly backwards for a security check: if payload content and VAA envelope ever diverge (e.g. a misconfigured or malicious source-chain contract embedding the wrong chain ID in its own outbound payload), the allowlist lookup would key off the wrong value. The BridgeTransferCompleted event had the same issue, reporting the unverified field. Use parsed.emitter_chain for both the TrustedEmitter lookup and the completion event. Closes #452 --- investment_vault/src/lib.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index 622ac77..cd57ca6 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -1366,11 +1366,13 @@ impl InvestmentVault { let parsed = client.verify_vaa(&vaa); let transfer = wormhole::parse_bridge_payload(&env, &parsed.payload); + // Trust decision keyed off the VAA envelope's guardian-verified origin + // chain, not the payload-embedded (unverified) transfer.source_chain (#452). let trusted: bool = env .storage() .persistent() .get(&BridgeDataKey::TrustedEmitter( - transfer.source_chain, + parsed.emitter_chain, parsed.emitter_address.clone(), )) .unwrap_or(false); @@ -1397,7 +1399,7 @@ impl InvestmentVault { lock_deposit(&env, &to); events::bridge_transfer_completed( &env, - transfer.source_chain, + parsed.emitter_chain, &parsed.emitter_address, &to, transfer.amount, From 50c438fe083d84980ca5cdb987d5f5383b14d977 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Mon, 24 Aug 2026 22:12:06 +0100 Subject: [PATCH 2/4] fix(investment_vault): verify transfer.token_address in complete_bridge_transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeTransferPayload.token_address exists specifically to identify which asset a cross-chain message concerns, and initiate_bridge_transfer sets it to the vault's own encoded address on the outbound side. But complete_bridge_transfer decoded the full payload and never once read transfer.token_address — it minted HBS purely from the trusted-emitter check plus recipient/amount, with no assertion the incoming message was actually about this vault's own token. Not exploitable in isolation today since the trusted-(chain, emitter) allowlist already gates who can trigger a mint, but if the same trusted emitter is ever reused for a multi-asset bridge (a common pattern) or a future version adds more token types, a VAA meant for a different asset would be silently accepted and minted as HBS. Assert transfer.token_address matches this vault's own contract address before minting. New VaultError::BridgeTokenMismatch. Closes #453 --- investment_vault/src/lib.rs | 8 ++++++++ investment_vault/src/types.rs | 3 +++ 2 files changed, 11 insertions(+) diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index cd57ca6..0a3bba1 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -1379,6 +1379,14 @@ impl InvestmentVault { if !trusted { panic!("emitter not trusted"); } + // The payload's token_address is decoded and is now checked (#453) rather + // than silently ignored — otherwise a VAA about a different asset would + // be accepted and minted as HBS anyway if the emitter is ever reused for + // a multi-asset bridge. + if transfer.token_address != wormhole::address_to_bytes32(&env, &env.current_contract_address()) + { + panic_with_error!(&env, VaultError::BridgeTokenMismatch); + } let digest: BytesN<32> = env.crypto().sha256(&vaa).into(); if env .storage() diff --git a/investment_vault/src/types.rs b/investment_vault/src/types.rs index 03a75b3..00c5ba0 100644 --- a/investment_vault/src/types.rs +++ b/investment_vault/src/types.rs @@ -95,6 +95,9 @@ pub enum VaultError { InvestmentCapExceeded = 43, /// Requested amount exceeds the configured MaxTransactionAmount compliance limit (#457). ExceedsMaxTransactionAmount = 44, + /// complete_bridge_transfer's decoded payload's token_address does not match + /// this vault's own contract address (#453). + BridgeTokenMismatch = 45, } #[contracttype] From c8e1894765050c7e25a923b819695579ee9f81d8 Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Mon, 24 Aug 2026 22:12:34 +0100 Subject: [PATCH 3/4] fix(investment_vault): verify transfer.target_chain in complete_bridge_transfer BridgeTransferPayload.target_chain is set by the sender on the outbound side to indicate which chain a transfer is destined for. Same gap as the token_address issue: complete_bridge_transfer decoded this field into transfer.target_chain but never read it anywhere, relying entirely on the trusted-emitter allowlist to prevent misuse rather than also checking the message actually claims Stellar as its destination. Assert transfer.target_chain == wormhole::chain_id::STELLAR before proceeding to mint. New VaultError::BridgeWrongTargetChain. Closes #454 --- investment_vault/src/lib.rs | 6 ++++++ investment_vault/src/types.rs | 3 +++ 2 files changed, 9 insertions(+) diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index 0a3bba1..3184acf 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -1379,6 +1379,12 @@ impl InvestmentVault { if !trusted { panic!("emitter not trusted"); } + // The payload's target_chain is decoded and is now checked (#454) rather + // than silently ignored — the field exists specifically to prevent a + // message meant for a different destination from being processed here. + if transfer.target_chain != wormhole::chain_id::STELLAR { + panic_with_error!(&env, VaultError::BridgeWrongTargetChain); + } // The payload's token_address is decoded and is now checked (#453) rather // than silently ignored — otherwise a VAA about a different asset would // be accepted and minted as HBS anyway if the emitter is ever reused for diff --git a/investment_vault/src/types.rs b/investment_vault/src/types.rs index 00c5ba0..0e18d55 100644 --- a/investment_vault/src/types.rs +++ b/investment_vault/src/types.rs @@ -98,6 +98,9 @@ pub enum VaultError { /// complete_bridge_transfer's decoded payload's token_address does not match /// this vault's own contract address (#453). BridgeTokenMismatch = 45, + /// complete_bridge_transfer's decoded payload targets a chain other than + /// Stellar (#454). + BridgeWrongTargetChain = 46, } #[contracttype] From 984ed5e8bb22d635d7b9752e2fe1817e7d68202a Mon Sep 17 00:00:00 2001 From: Temi-suwa18 Date: Mon, 24 Aug 2026 22:12:46 +0100 Subject: [PATCH 4/4] fix(project_registry): bound create_proposal's description length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_project's uri is strictly bounded (MIN_URI_LEN/MAX_URI_LEN) explicitly to prevent excessively large ledger entries, but create_proposal's description had no equivalent bound — any whitelisted address (not just the owner) could call create_proposal with an arbitrarily large description, stored indefinitely as part of the Proposal persistent entry. Distinct from #332, which covers compact_storage's unbounded Vec params and the missing voting_duration_secs upper bound, not description's length. Add MAX_PROPOSAL_DESCRIPTION_LEN (2048 bytes), mirroring the URI-length pattern, with a new RegistryError::ProposalDescriptionTooLong variant. Closes #455 --- project_registry/src/lib.rs | 6 ++++++ project_registry/src/types.rs | 2 ++ 2 files changed, 8 insertions(+) diff --git a/project_registry/src/lib.rs b/project_registry/src/lib.rs index 37b64e3..221669d 100644 --- a/project_registry/src/lib.rs +++ b/project_registry/src/lib.rs @@ -12,6 +12,9 @@ use stellar_macros::only_owner; const MAX_URI_LEN: u32 = 512; /// Minimum URI length — must contain at least a scheme and one character (#117). const MIN_URI_LEN: u32 = 8; +/// Maximum governance proposal description length in bytes, mirroring the +/// URI-length bound above — prevents excessively large ledger entries (#455). +const MAX_PROPOSAL_DESCRIPTION_LEN: u32 = 2048; /// Current schema version for instance and persistent contract state (#66). const STATE_VERSION: u32 = 1; @@ -551,6 +554,9 @@ impl ProjectRegistry { if voting_duration_secs < MIN_VOTING_PERIOD { panic_with_error!(&env, RegistryError::VotingPeriodTooShort); } + if description.len() > MAX_PROPOSAL_DESCRIPTION_LEN { + panic_with_error!(&env, RegistryError::ProposalDescriptionTooLong); + } let counter: u32 = env .storage() .instance() diff --git a/project_registry/src/types.rs b/project_registry/src/types.rs index 0051d8e..3e32516 100644 --- a/project_registry/src/types.rs +++ b/project_registry/src/types.rs @@ -92,6 +92,8 @@ pub enum RegistryError { BatchTooLarge = 37, /// Project is already certified with the target status. AlreadyCertified = 38, + /// create_proposal's description exceeds MAX_PROPOSAL_DESCRIPTION_LEN (#455). + ProposalDescriptionTooLong = 39, } /// Certification state for a green project (#130).