Repository navigation
fix(soroban): require authorization to install the contract admin - #540
Open
zzzbedream wants to merge 2 commits into
Open
zzzbedream wants to merge 2 commits into
zzzbedream wants to merge 2 commits into
Conversation
Closes ZyntariHQ#529 `initialize()` was an unprotected initializer: it checked `has_admin()` and then called `set_admin()` with no `require_auth()` on the address being installed and no authorisation check on the caller. Deployment and initialisation were separate transactions, so anyone observing the ledger could claim the admin role, permanently and unrecoverably. Two changes, because `require_auth()` alone does not close the race — an attacker can still install, and sign for, their own address: - `__constructor(admin: Option<Address>)` installs the admin inside the deploy transaction. `Some(admin)` is the new documented deployment path and leaves no ledger state in which the contract exists without an admin. `None` keeps the uninitialised state representable for the migration and upgrade paths (and their tests) and defers to `initialize()`. - `initialize()` now requires `admin.require_auth()`, so the role can never be assigned to an address that did not consent to receive it. Both entry points share `init_contract_state()` so they cannot drift. Tests: 25 new tests, of which 20 run under real, unmocked authorisation (`Env::set_auths(&[])`) instead of `mock_all_auths()` — every admin-gated entry point now has one. Removing any `require_auth()` from the contract fails them. Ops: deploy.sh and the Makefile deploy target pass the admin as a constructor argument and then verify the on-chain admin before the contract is put into service; the manifests gain `expected_admin` and document deploy-and-initialise as one indivisible step.
Two pre-existing defects found while auditing entry points for ZyntariHQ#529. Both are independent of the admin-authorisation fix; they are folded in here because the audit that surfaced them is part of that issue's scope. - `examples/e2e-test.ts` and `examples/query-payment.ts` both called `getPaymentCount()` with no arguments, but the signature has been `getPaymentCount(adminPublicKey: string)` since ZyntariHQ#512 made `payment_count` admin-gated. Neither has compiled since. `e2e-test.ts` already has the admin keypair to hand. `query-payment.ts` is a public read-only script with no admin credentials, so the count is now queried only when `ADMIN_PUBLIC_KEY` is set and skipped with a note otherwise, keeping the script usable by a non-admin caller. - `tsconfig.test.json` now covers `examples/` as well as `src/`, so `npm run typecheck` catches this class of breakage. `examples/` was excluded from both tsconfigs, which is why two call sites could rot unnoticed across two releases. `tsconfig.json` (the build) is unchanged and still emits only `src/` to `dist/`. - `dist/` was committed but stale: it was missing `LegacyPaymentsMigratedEvent` and `IssuersMigratedEvent`, both present in `src/events.ts` and both absent from the `DecodedSorobanEvent` union that consumers match on. Rebuilt with `npm run build`.
There was a problem hiding this comment.
Pull request overview
This PR addresses a critical Soroban contract takeover vulnerability by making admin installation authorization-enforced and (crucially) enabling atomic “deploy + initialize” via a constructor argument, eliminating the deploy-to-initialize front‑run window. It also updates ops tooling/manifests and adds extensive “real authorization” regression tests to prevent future auth omissions from being hidden by mock_all_auths().
Changes:
- Adds
__constructor(admin: Option<Address>)to atomically install the admin during deployment, and updatesinitialize()to requireadmin.require_auth(). - Expands the test suite with real (unmocked) authorization checks across admin-gated entrypoints; updates contract test snapshots accordingly.
- Updates deployment manifests/scripts and TypeScript client examples/typecheck coverage to align with admin-gated reads and the new deployment path.
Reviewed changes
Copilot reviewed 217 out of 224 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
soroban/contracts/invoice-payment/src/lib.rs |
Adds __constructor and tightens initialize() authorization; shares init logic via helper. |
soroban/contracts/invoice-payment/src/migration.rs |
Updates migration test setup to register via constructor path. |
soroban/contracts/invoice-payment/schema.json |
Documents new __constructor entrypoint and updates auth classification for initialize. |
soroban/contracts/invoice-payment/generate-schema.sh |
Adds __constructor and updates method auth/description metadata generation. |
soroban/manifests/testnet.toml |
Adds expected_admin documentation/config for deploy verification flow. |
soroban/manifests/mainnet.toml |
Adds expected_admin documentation/config for deploy verification flow. |
soroban/deploy.sh |
Deploys with constructor arg and verifies on-chain admin (issues noted in review comments). |
soroban/client/tsconfig.test.json |
Includes examples/ in typechecking and sets rootDir to support it. |
soroban/client/examples/query-payment.ts |
Makes admin-gated getPaymentCount conditional on ADMIN_PUBLIC_KEY. |
soroban/client/examples/e2e-test.ts |
Updates example call to getPaymentCount(adminPublicKey). |
soroban/client/dist/index.d.ts |
Re-exports newly added event types in generated typings. |
soroban/client/dist/events.d.ts |
Updates DecodedSorobanEvent union to include additional event types. |
soroban/client/dist/index.js.map |
Regenerated build output. |
soroban/contracts/invoice-payment/test_snapshots/** |
Snapshot updates reflecting constructor auth entries and related ledger/auth footprint changes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
52
to
+61
| # Parse TOML values with grep (no external deps required) | ||
| _toml_get() { | ||
| grep -E "^${1}\s*=" "$MANIFEST_FILE" | head -1 | sed 's/^[^=]*=\s*//' | tr -d '"' | tr -d "'" | ||
| } | ||
|
|
||
| MANIFEST_IDENTITY="$(_toml_get identity.name 2>/dev/null || true)" | ||
| MANIFEST_WASM_PATH="$(_toml_get wasm_path 2>/dev/null || true)" | ||
| MANIFEST_CONTRACT_ID_FILE="$(_toml_get contract_id_file 2>/dev/null || true)" | ||
| MANIFEST_SECRET_KEY_ENV="$(_toml_get secret_key_env 2>/dev/null || true)" | ||
| MANIFEST_EXPECTED_ADMIN="$(_toml_get expected_admin 2>/dev/null || true)" |
Comment on lines
100
to
114
| # Step 1: Create or verify identity | ||
| echo "🔑 Step 1/4: Setting up identity '$IDENTITY'..." | ||
| echo "" | ||
|
|
||
| if stellar keys show "$IDENTITY" &> /dev/null; then | ||
| echo "✅ Identity '$IDENTITY' already exists" | ||
| ADMIN_ADDRESS=$(stellar keys address "$IDENTITY") | ||
| echo " Address: $ADMIN_ADDRESS" | ||
| else | ||
| echo "📝 Creating new identity '$IDENTITY'..." | ||
| stellar keys generate --global "$IDENTITY" --network "$NETWORK" | ||
| ADMIN_ADDRESS=$(stellar keys address "$IDENTITY") | ||
| echo "✅ Identity created" | ||
| echo " Address: $ADMIN_ADDRESS" | ||
| fi |
Comment on lines
+67
to
70
| # Address the contract admin MUST end up being. Empty means "whatever the | ||
| # identity resolves to" (see the manifest comment on expected_admin). | ||
| EXPECTED_ADMIN="${INVOISIO_EXPECTED_ADMIN:-$MANIFEST_EXPECTED_ADMIN}" | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #529
The problem
initialize()was an unprotected initializer. It checkedhas_admin()and thencalled
set_admin()with norequire_auth()on the address being installed andno authorisation check of any kind on the caller. Deployment and initialisation
were separate transactions, so anyone observing the ledger between the two could
call
initialize()with their own address and take the contract.The admin is not a nominal role — it is the only address that can call
record_payment,allow_asset,revoke_asset,set_allow_native,set_paused,upgrade,upgrade_storage,rebuild_history_indexandpropose_admin. Theoutcome is unrecoverable: once an attacker holds the role,
initialize()returnsAlreadyInitializedto the legitimate operator forever, andpropose_adminrequires the current admin's authorisation, so the role cannot be taken back.
Why
require_auth()alone does not fix thisThis is the part worth reviewing carefully. Adding
admin.require_auth()toinitialize()satisfies the letter of the issue title, but it does not closethe race. An attacker can call
initialize()with their own address and signfor it themselves —
require_auth()is satisfied, and they still win. Allrequire_auth()buys is that the role cannot be assigned to an address that didnot consent to receive it.
Closing the race requires removing the window itself, which means initialising
inside the deploy transaction.
The fix
Two changes:
__constructor(admin: Option<Address>)installs the admin inside thedeploy transaction.
Some(admin)is the new documented deployment path andleaves no ledger state in which the contract exists without an admin — the
front-run window is gone, not narrowed.
Nonedeploys uninitialised, keepingthat state representable for the migration and upgrade paths (and their tests)
and deferring to
initialize().initialize()now requiresadmin.require_auth(), so the role can neverbe assigned to an address that did not consent to receive it.
Both entry points share
init_contract_state()so their behaviour cannot drift.Acceptance criteria
initialize()fails unless the installed admin authoriseslib.rs—admin.require_auth()ininitializetest_initialize_cannot_install_an_unconsenting_third_party_as_adminmock_all_authstest_initialize_rejected_under_real_authorizationenforce_real_authtests, 1:1deploy.sh,Makefile,manifests/*.tomlTesting
25 new tests (206 → 231). 20 of them run under real, unmocked authorisation via
Env::set_auths(&[])rather thanmock_all_auths()— one for each of the 20authorisation-gated entry points in
schema.json.The issue notes that the existing suite could not have caught this, because
mock_all_auths()appears in 116 places and makes everyrequire_auth()unconditionally satisfied. These tests hand the environment back to the host's
real authorisation machinery. Each one deliberately passes the legitimate admin
address, so the
admin == get_admin()comparison succeeds and execution actuallyreaches the authorisation check — the assertion is that not even the real admin
can act without a real signature. They assert
Err(Err(InvokeError::Abort))rather than a bare
is_err(), so a call failing for an unrelated contract reason(
NotInitialized,MustBePausedForUpgrade) cannot be mistaken for proof thatthe entry point is gated.
These tests are not decorative — mutation testing:
require_auth()frominitializerequire_auth()from__constructorrequire_auth()fromset_allow_nativeOps
deploy.shand theMakefiledeploy target now pass the admin as a constructorargument, so deployment and initialisation are one indivisible transaction, and
then read the on-chain admin back and compare it against the expected value
before the contract is put into service. The manifests gain
expected_adminanddocument the procedure as a single atomic step.
Audit of the remaining entry points
Every other authorisation-gated entry point was checked for the same class of
omission. All 20 are correctly gated;
initializewas the only one missing acheck. Each now has a real-authorisation regression test, so a future removal
fails a test instead of passing silently.
The audit also surfaced three pre-existing defects, unrelated to the
authorisation model but found on the way. All three are fixed here, in a
separate commit from the security fix so the latter stays reviewable on its own:
Two broken example call sites.
examples/e2e-test.tsandexamples/query-payment.tsboth calledgetPaymentCount()with noarguments, but the signature has been
getPaymentCount(adminPublicKey: string)since Reconcile the contract's public enumerable payment ledger with the product's privacy guarantees #512 made
payment_countadmin-gated. Neither has compiled since.e2e-test.tsalready has the admin keypair to hand;query-payment.tsis apublic read-only script, so the count is now queried only when
ADMIN_PUBLIC_KEYis set and skipped with a note otherwise, keeping thescript usable by a non-admin caller.
The blind spot that let them rot.
examples/was excluded from bothtsconfigs, so nothing typechecked it.
tsconfig.test.jsonnow coversexamples/as well assrc/, which puts this class of breakage in front ofnpm run typecheck.tsconfig.json(the build) is unchanged and still emitsonly
src/todist/.Stale committed
dist/. It was missingLegacyPaymentsMigratedEventandIssuersMigratedEvent, both present insrc/events.tsand both absent fromthe
DecodedSorobanEventunion consumers match on. Rebuilt withnpm run build.One more pre-existing defect is fixed in the security commit itself, because it
blocked the work:
contracts/invoice-payment/Makefileused 8 spaces instead of aTAB in the
fmt-checkrecipe, so GNU Make refused to parse the file at all(
Makefile:17: *** missing separator). No target worked — including themake schemasuggested by the maintainer on #484. One character.Observation, not changed here:
soroban.ymlnever runs the TypeScriptclient's
npm run typecheckornpm testin either job. The ABI drift jobchecks the client against
schema.json, but the client's own types and unittests are not exercised in CI, and nothing rebuilds or diffs
dist/. That iswhat allowed defects 1 and 3 to survive two releases. Adding those steps felt
like scope this PR should not take unilaterally — happy to add them if you want.
Open question
CONTRACT_VERSIONis still1.0.0. Adding__constructoris an ABI addition,but the previous ABI-changing PRs (#512, #508) did not bump it either, so I have
not changed the policy unilaterally. Tell me which you want and I will adjust.
Verification
Reproduced the
soroban.ymlCI steps locally against this branch (both commitsapplied), on a clean clone of
main:cargo test --lockedcargo clippy --locked -- -D warningscargo fmt -- --checkmigration_helpersguardcargo build --locked --target wasm32v1-none --releasegenerate-schema.sh+git diff --exit-code schema.jsonscripts/check-client-drift.mjscargo testtsc --noEmit/vitest runNote on the WASM size warning: the build is 119 KB, over the workflow's 100 KB
warning threshold. That threshold is already exceeded on
main(117 KB beforethis change), so the warning is pre-existing; this PR adds 2,172 bytes.