Payments 7/8: Security hardening of payment authorization boundaries (guardian multisig invoker fix + auth audit) - #152
Conversation
…undary audit Closes two critical authorization gaps (issue LadderMine#144): - GuardianMultisig::confirm_veto used env.current_contract_address() as a placeholder "caller" (the multisig's own address, not any invoker — Soroban has no implicit msg.sender) and never called require_auth() on anything meaningful. Confirmations were tracked by pushing a clone of governance_contract, not the confirming owner, which also meant the first-ever confirmation for a proposal made every later confirmation attempt panic with "already confirmed" — with threshold > 1 a veto could never actually execute. Fixed by taking an explicit `owner: Address` parameter, calling owner.require_auth(), verifying owner is a registered owner, and deduplicating/tracking by that real address — the standard Soroban pattern for "any one of N may call, but must prove which one," matching the shape the TS SDK's Signer/WalletAdapter interfaces already use. - VaultL3/VaultL6/VaultL12's deposit/withdraw/early_exit/relock all gated on user.require_auth() instead of admin.require_auth() (admin = VaultRouter) — only VaultFlex had the correct pattern. This let a caller invoke a tier vault directly with only their own signature, bypassing VaultRouter's pause flag and asset allowlist, and — most severely for deposit — the actual token transfer VaultRouter performs before invoking the vault, letting a caller mint themselves shares and balance for free with no principal ever deposited. Fixed by gating all four functions on admin.require_auth() in all three vaults, matching VaultFlex's already-correct pattern exactly. Both fixes are covered by regression tests that mock auth for the wrong address only (never admin/never the true owner) and assert the call is now rejected — every one of these would have passed silently against the pre-fix code. A companion positive test per contract proves the router-mediated / correctly-authorized path still works. audit/authorization-boundaries-2026-08.md is the committed authorization-boundary review the issue's Definition of Done requires: a who-can-call-what table for every payment-moving entrypoint, the two findings above, a re-entrancy review of the two-hop transfer pattern under Soroban's host-function model (extending internal-2026-01.md's stated Governance-focused follow-up to the payment path — conclusion: withdraw/early_exit's effects-before-interaction ordering is safe; deposit's ordering is technically interactions-first but low-risk given Soroban tokens don't invoke recipient callbacks; the real reentrancy control is the asset allowlist), and a documented known limitation (no owner-rotation function exists yet, so the "owner rotated mid-veto" edge case can't be exercised against real behavior — the fix is already robust to it in spirit since owner-set membership is re-read fresh on every call). Note: could not run `cargo test` locally (this environment's linker toolchain is broken, unrelated to this change) — verified by careful manual review instead. CI's "Soroban contracts" job itself only runs `cargo build --target wasm32-unknown-unknown --release`, which doesn't compile #[cfg(test)] code either way, so this doesn't change what CI verifies; flagging the gap honestly rather than claiming local verification that didn't happen.
|
@Leothosine is attempting to deploy a commit to the wumibals' projects Team on Vercel. A member of the Team first needs to authorize it. |
wumibals
left a comment
There was a problem hiding this comment.
Reviewed the payment authorization boundary hardening PR (closes #144). Both fixes address genuinely critical vulnerabilities, not defense-in-depth hardening.
- C-01 is the more severe of the two: three of four tier vaults gating
deposit/withdraw/early_exit/relockonuser.require_auth()instead ofadmin.require_auth()(whereadmin= the registeredVaultRouter) meant anyone could call a tier vault directly, bypassingVaultRouter's pause flag and asset allowlist entirely. Fordepositspecifically, since each vault trusts thatVaultRouteralready moved the tokens before invoking it (documented as "bookkeeping only"), a direct call let a caller mint themselves shares/balance for free with zero principal ever deposited — a real fund-draining vulnerability, not a theoretical one. The fix correctly brings all three vaults in line withVaultFlex's already-correct pattern rather than inventing a new one. - C-02 is a subtler but still serious bug:
confirm_vetorecordingenv.current_contract_address()(the multisig's own address) instead of the actual confirming owner meant every confirmation after the first panicked as "already confirmed" — so with anythreshold > 1, a veto could never execute at all. That's a silent governance-availability failure that only a1-of-1test could have masked, which is exactly what happened. Requiring an explicitowner: Addressparameter withowner.require_auth()is the correct Soroban pattern here (no implicitmsg.senderexists), and it's good that this is called out explicitly as the standard idiom rather than a one-off fix. - The regression tests are well-designed in principle: mocking auth for the wrong address and asserting rejection, with a companion positive test proving the correct path still works, plus a test with no auth mocking at all for
confirm_vetospecifically to proverequire_auth()is genuinely enforced (not just present in the source). That's a meaningfully stronger test than most authorization regression suites bother with. - The committed audit doc (
audit/authorization-boundaries-2026-08.md) — a who-can-call-what table across every payment-moving entrypoint, a re-entrancy review of the two-hop transfer pattern, and an honestly-documented known limitation (no owner-rotation function exists yet, so that edge case can't be exercised against real behavior) — is real due diligence, not a checkbox. Flagging the separateVaultFlexmax_tvlfunctional gap as out-of-scope rather than scope-creeping it into this PR is the right call for a focused security fix. - The disclosure on testing is honest and appreciated:
cargo testcould not be run locally due to a broken linker (unrelated to this change), and this repo's CI only runscargo build, which doesn't compile#[cfg(test)]code at all. That means the new regression tests — including the "no auth mocking" test that's the strongest evidence the fix actually works — have not been executed by anyone yet. The actual production code paths (therequire_auth()changes themselves) did compile successfully under CI, and manual line-by-line review is a reasonable stopgap for a security fix of this kind, but the test suite itself remains unverified pending a realcargo testrun — worth prioritizing given both bugs being fixed here are genuinely severe.
CI is green across Soroban contracts (build), Next.js dashboard, and TypeScript SDK. Vercel's FAILURE is the usual unauthorized deployment integration link, unrelated to the code.
Approving — these are correct, well-reasoned fixes for two critical authorization gaps; get cargo test running against this branch (or wire it into CI) as a priority follow-up before relying on the new regression suite as the safety net it's meant to be.
Summary
Closes two critical authorization gaps (issue #144), post-#138's vault refactor.
C-01 — Three of four tier vaults gated mutating functions on the wrong address
VaultL3,VaultL6, andVaultL12'sdeposit/withdraw/early_exit/relockall gated onuser.require_auth()— onlyVaultFlexcorrectly gated onadmin.require_auth()(admin= the registeredVaultRouteraddress). This let a caller invoke a tier vault directly, bypassingVaultRouter's pause flag and asset allowlist, and — most severely fordeposit— the actual token transferVaultRouterperforms before invoking the vault (each vault'sdepositis documented as "bookkeeping only," trusting the router already moved the tokens). A direct call let a caller mint themselves shares/balance for free with no principal ever deposited.Fix: all four functions in all three vaults now gate on
admin.require_auth(), matchingVaultFlex's already-correct pattern exactly.C-02 —
GuardianMultisig::confirm_vetonever attributed confirmations to a real callerconfirm_vetosetcaller = env.current_contract_address()(the multisig's own address — Soroban has no implicitmsg.sender), never calledrequire_auth()on anything meaningful, and pushed a clone ofgovernance_contract(not the confirming owner) into the confirmations list — meaning the first-ever confirmation for a proposal made every later confirmation attempt panic with "already confirmed." With anythreshold > 1, a veto could never actually execute; only the existing1-of-1test masked this.Fix:
confirm_vetonow takes an explicitowner: Address, callsowner.require_auth(), verifies membership in the registered owner set, and tracks confirmations by the real owner address — the standard Soroban pattern (no implicit invoker exists for a direct call; the caller must name and authorize the address they're acting as), matching the shape the TypeScript SDK'sSigner/WalletAdapterinterfaces already use.What's committed alongside the fixes
guardian_multisigthat mock auth for the wrong address only (neveradmin, never the true owner) and assert the call is now rejected — every one of these would have passed silently against the pre-fix code. A companion positive test per contract proves the correctly-authorized path still works.guardian_multisigadditionally gets: non-owner rejection, same-owner double-confirm rejection, exactly-once-at-threshold dispatch verification (via aMockGovernancethat counts calls), per-(governance_contract, proposal_id)scoping, and a test with no auth mocking at all provingrequire_auth()is genuinely enforced (this specific test would not have panicked against the pre-fix placeholder).audit/authorization-boundaries-2026-08.md— the committed authorization-boundary review the issue's Definition of Done requires: a who-can-call-what table for every payment-moving entrypoint (deposit,withdraw,early_exit,relock,set_max_tvl,pause/unpause,confirm_veto), a re-entrancy review of the two-hop transfer pattern under Soroban's host-function model (extendinginternal-2026-01.md's stated Governance-focused follow-up to the payment path), and a documented known limitation: no owner-rotation function exists inGuardianMultisigyet, so the "owner rotated mid-veto" edge case can't be exercised against real behavior — the fix is already robust to it in spirit (owner-set membership is re-read fresh on every call), but a future rotation function would need to explicitly decide how to handle in-flight confirmations naming a removed owner.VaultFlexdoesn't implementmax_tvl/remaining_capacity/set_max_tvl, soVaultRouter.vault_capacity(Tier::Flex)/set_max_tvl(Tier::Flex, ...)would fail at runtime — a functional gap, not an auth one, flagged for a separate issue.Test plan
cargo test— could not run locally: this environment's Rust linker toolchain is broken (unrelated to this change —link.exemisresolution during build-script compilation). Flagging this honestly rather than claiming a local run that didn't happen. CI's "Soroban contracts" job itself only runscargo build --target wasm32-unknown-unknown --release, which does not compile#[cfg(test)]code either way, so this PR's non-test code (the actual fixes) is what that job verifies; the new tests are the ones asking for a realcargo testrun as a follow-up if this repo's CI is ever extended to include one.app/orsdks/changed.Closes #144