security: validate contract inputs and bound registry metadata (WNS-03, WNS-09, WNS-10, WNS-11, WNS-12) - #551
security: validate contract inputs and bound registry metadata (WNS-03, WNS-09, WNS-10, WNS-11, WNS-12)#551heifner wants to merge 3 commits into
Conversation
…3, WNS-09, WNS-10, WNS-11, WNS-12) The remaining input-validation findings from CertiK "Wire Network - Sysio Audit 1". No ABI changes — every fix is an added guard. [Major] roa::reducepolicy accepted negative NET/CPU/RAM weights. The action bounded the request only from above (`w <= stored`), a condition any negative amount satisfies whenever the stored weight is positive. The weight is then applied as a SUBTRACTION, so a negative one INCREASED the account's quota: `new_net = max(0, net_limit - net_weight.amount)`. A node owner could inflate an account's NET/CPU past the issuer's ROA budget — bypassing expandpolicy's free-allocation check — and desynchronise the reslimit row and the issuer's nodeowners accounting from the policy weights. addpolicy and expandpolicy both reject negatives already; reducepolicy was the outlier. CertiK's PoC (a 10.0000 SYS policy reduced by -5.0000 SYS ending at 15.0000 SYS) is now a regression test asserting the policy and the account's resource limits are untouched. [Minor] token::create stored an unvalidated issuer. `issuer` was trusted input after `require_auth(get_self())`, but `issue` gates on `to == st.issuer` + `require_auth(st.issuer)`. A null or non-existent issuer therefore produced a token nobody could ever issue while permanently burning the symbol, since `create` rejects duplicates. Now checks `issuer.value != 0` and `is_account(issuer)`, after the supply checks so existing error ordering holds. [Minor] Registry metadata was unbounded in system-paid state. `tokens::regtoken` moved `symbol_name` and `description` into a persisted row billed to `ram_payer = sysio` — the shared system pool — without bounding either, letting each unique code consume up to the KV/action ceiling. The identical unbounded pair, with the same billing, was on `chains::regchain` and `reserv::regreserve`, so the limits live once in a new shared header (`sysio.opp.common/registry_metadata.hpp`: 32-byte label, 256-byte description — the latter matching the established `token::issue` memo bound) and all three enforce them via `check_metadata` before emplace. `reserv::oncrtreserve` carries the same two strings from an outpost-side creator but is an OPP inbound dispatch handler, which must never abort: a check() there rolls back the consensus-tipping delivery and stalls epoch advancement chain-wide. It uses the non-throwing `metadata_exceeds_bounds` and joins the existing reject predicate, releasing the creator's escrow through the RESERVE_CREATE_CANCELLED flow already used for an invalid amount or an unlinked creator. The CANCELLED tombstone is itself a sysio-billed row, so its metadata is clamped via truncate_label/truncate_description — storing it verbatim would persist exactly the state the bound prevents. [Informational] regchain did not enforce the depot's canonical code. Bootstrap invariant V3 (docs/platform-bootstrap-config.md) is "exactly one CHAIN_KIND_WIRE chain, code WIRE". Only the cardinality half was on-chain, and `is_depot` is derived from the kind alone, so a registration could claim depot identity under any code and the code's validity rested entirely on the off-chain config validator. [Optimization] Removed the unused sysiosystem::system_contract forward declaration from sysio.token.hpp — it implied a sysio.token -> sysio.system dependency that does not exist. Verified: contracts_unit_test 588 cases, unit_test 1515 cases, plugin_test — all green, no errors. Change-Id: Ia903c97aace16597352dd18e9a892568f17e5433
huangminghuang
left a comment
There was a problem hiding this comment.
Two actionable findings from the delegated OCR review.
| // under any code (e.g. `FAKE`) -- previously only the cardinality half was on-chain | ||
| // and the code depended entirely on the off-chain config validator. | ||
| if (kind == opp::types::CHAIN_KIND_WIRE) { | ||
| sysio::check(code == WIRE_CHAIN_CODE, |
There was a problem hiding this comment.
[P2] Reserve the WIRE code bidirectionally
This guard only handles kind == CHAIN_KIND_WIRE. During bootstrap, regchain(CHAIN_KIND_EVM, "WIRE", ...) can still succeed first; code uniqueness then permanently prevents registering the canonical depot row, and there is no erase action. Enforce the inverse implication too: code == WIRE_CHAIN_CODE must require kind == CHAIN_KIND_WIRE, with a regression test for this ordering.
| inline std::string truncate_label(std::string label) { | ||
| if (label.size() > label_max_bytes) label.resize(label_max_bytes); | ||
| return label; | ||
| } | ||
|
|
||
| /// Clamp a description for storage on a reject-path tombstone row. See `truncate_label`. | ||
| inline std::string truncate_description(std::string description) { | ||
| if (description.size() > description_max_bytes) description.resize(description_max_bytes); |
There was a problem hiding this comment.
[P3] Preserve UTF-8 when truncating metadata
resize() truncates bytes rather than UTF-8 code points. A valid 33-byte label consisting of 31 ASCII bytes plus é is cut midway through the final character, persisting malformed human-readable metadata in the CANCELLED row. Clamp at a valid UTF-8 boundary or store a safe fixed/empty tombstone value, and add a multibyte boundary test.
…F-8 boundaries Addresses review on #551 (both findings). [P2] Reserve the `WIRE` code from every non-WIRE kind. The guard only enforced the forward implication (kind WIRE => code WIRE), so `regchain(CHAIN_KIND_EVM, "WIRE", ...)` still succeeded. Chain codes are unique and there is NO erase action, so such a row would permanently squat the depot's identity and leave the canonical self-row unregisterable — bricking bootstrap with no on-chain recovery. The inverse is now enforced too, with a distinct message so the two failures stay diagnosable. The regression test attempts the squat BEFORE the depot row exists, since the ordering is the whole point, then asserts the canonical row still registers. [P3] Clamp tombstone metadata on a UTF-8 code-point boundary. `resize()` cuts at a byte offset, not a character boundary: a 33-byte label of 31 ASCII bytes plus `é` (0xC3 0xA9) clamped to 32 kept a lone 0xC3 lead byte and persisted malformed text in state. `clamp_utf8` walks back off continuation bytes so a straddling character is dropped whole. The bound itself stays a BYTE bound — it exists to cap state size. Test asserts the stored label is the 31 ASCII bytes, not 32. Verified: contracts_unit_test 590 cases, no errors. Change-Id: Ibfd23d34b8b9220113761ad545017f8a2c011895
|
Both findings were real and are fixed in 537d436. Thanks — P2 in particular was a genuine gap, not just a hardening nit. [P2] Reserve the You're right that the forward implication alone left the hole open, and the consequence is worse than a mis-registration: chain codes are unique and there is no erase action, so an EVM/SVM row registered under
[P3] Preserve UTF-8 when truncating — confirmed and fixed. Your example is exactly what it did: 31 ASCII +
Verification: |
huangminghuang
left a comment
There was a problem hiding this comment.
One low-severity project-rule finding from the follow-up review.
| // character straddling the boundary is being split, so walk back onto its lead byte and | ||
| // drop the whole sequence. Terminates at 0 in the worst case. | ||
| std::size_t cut = max_bytes; | ||
| while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut; |
There was a problem hiding this comment.
[P3] Name the UTF-8 bit masks — The repository’s “No magic literals” rule requires nontrivial numeric values behind named constants. These masks define the correctness of the UTF-8 boundary check, so introduce named inline constexpr values for 0xC0 and 0x80 and use them here.
Addresses review on #551 (the UTF-8 bit-mask comment, by removing the code it refers to). The tombstone no longer carries the creator's over-bound strings at all: `name` becomes the fixed marker `<rejected>` and `description` is cleared. That deletes `clamp_utf8`, `truncate_label`, `truncate_description` and the boundary test along with the masks. Truncation was the wrong tool for this slot. It has to cut at a byte offset rather than a UTF-8 code-point boundary, so it needs the boundary walk purely to avoid corrupting its own input — and the text it salvages buys nothing: nothing reads a tombstone's metadata (the reclaim path overwrites every field) and the creator's originals are preserved in the inbound OPP envelope artifact regardless. A fixed marker also states plainly that the row was rejected rather than leaving a blank a reader has to interpret. Worth recording for anyone revisiting this: the bound remains a state-size control, NOT a UTF-8 guarantee. `check_metadata` measures bytes only, so malformed input under the bound still reaches state verbatim — and that is harmless, because `fc::json::escape_string` already calls `prune_invalid_utf8` before emitting, so no malformed byte reaches a client through get_table_rows. The substitution is conditional on `oversized_metadata`. The other two reject reasons on that shared predicate — an unlinked creator and an invalid amount — carry perfectly valid, in-bounds metadata, so they keep it exactly as before. The regression test now covers both an ASCII and a multibyte over-bound label: with nothing truncated there is no code-point boundary left to split. Verified: contracts_unit_test 589 cases, no errors. Change-Id: I037004f1f8b8d6a00ea436d69b32dde6fb0b945d
|
Resolved in 06c9473, but by deleting the code rather than naming the masks — the mask comment is correct, and prompted a look at whether the boundary walk was earning its place. It wasn't. The tombstone no longer carries the over-bound strings at all. Two things that made truncation the wrong tool for this slot:
One correctness point worth flagging, since it isn't visible from the diff alone: the substitution is conditional on The regression test now covers an ASCII and a multibyte over-bound label, which is your multibyte case answered from the other direction: with nothing truncated there is no code-point boundary left to split. Verification: Separately, and unrelated to this change — on one full-suite run I saw |
|
Correction to my note above: the intermittent failure is not related to WNS-07 / WIRE-321. That guess was wrong, and the real cause is unrelated to this PR. Running it down properly — master baselined at 580 cases, this branch at 589 — the failures turned out to be a wall-clock budget in the test harness, not a logic defect: // libraries/testing/tester.cpp:123
const fc::microseconds base_tester::abi_serializer_max_time{1000*1000}; // 1s for slow test machinesThat accounts for every observation. The failing test wanders across unrelated suites run to run — Measured tally: 3 failures across 13 full runs on this branch, 0 across 6 on master. I'd caution against reading the master number as a clean bill of health — at the observed ~23% rate, P(0 failures in 6) ≈ 0.21, so master's baseline is compatible with the same flakiness rather than distinguishable from it. The exception text is what settles this, not the counts. The one part genuinely attributable here: this PR adds nine test cases, so each full run does marginally more work and has marginally more exposure to the deadline. That is a magnitude nudge on a pre-existing harness limit, not something introduced by the change. |
huangminghuang
left a comment
There was a problem hiding this comment.
One low-severity test-coverage finding from the latest follow-up review.
| ("token_code", codename_mvo("ETH")) | ||
| ("reserve_code", codename_mvo(reserve_code)) | ||
| ("name", name) | ||
| ("description", "") |
There was a problem hiding this comment.
[P3] Cover the oversized-description path — This regression test always passes an empty description, so it never exercises the description.size() > description_max_bytes half of metadata_exceeds_bounds or verifies that an over-bound description cannot be persisted. Add a description-only case with an in-bound name and a 257-byte description, then assert CANCELLED status, the <rejected> marker, and an empty stored description.
The remaining input-validation findings from CertiK "Wire Network - Sysio Audit 1", closing WIRE-318, WIRE-323, WIRE-324, WIRE-325 and WIRE-326.
No ABI changes — every fix is an added guard, so no
SysioContractTypes.tsregeneration and no downstreamwire-libraries-ts/wire-tools-tsrebuild is required. Only the five.wasmartifacts changed; no.abidid.roa::reducepolicyaccepts negative weights and expands quotastoken::createstores the issuer without validating ittokens::regtokendoes not boundsymbol_name/descriptionregchaindoes not enforce the canonicalWIREcodesysiosystem::system_contractforward declarationWNS-03 — negative reduction weights inflate quota
reducepolicybounded the request only from above (w <= stored), which any negative amount satisfies whenever the stored weight is positive. The weight is then applied as a subtraction, so a negative one increased the account's quota:A node owner could inflate an account's NET/CPU past the issuer's ROA budget — bypassing
expandpolicy's free-allocation check — and desynchronise thereslimitrow and the issuer'snodeownersaccounting from the policy weights.addpolicyandexpandpolicyboth reject negatives already;reducepolicywas the outlier.CertiK's PoC (a 10.0000 SYS policy reduced by −5.0000 SYS ending at 15.0000 SYS) is now
reducepolicy_negative_weight_rejected, covering all three weights and asserting the policy and the account's on-chain resource limits are untouched after the rejected attempts.WNS-09 — unvalidated issuer
issuerwas trusted input afterrequire_auth(get_self()), butissuegates onto == st.issuer+require_auth(st.issuer). A null or non-existent issuer produced a token nobody could ever issue while permanently burning the symbol, sincecreaterejects duplicates. Now checksissuer.value != 0andis_account(issuer), placed after the supply checks so existing error ordering is preserved.WNS-10 — unbounded metadata in system-paid state
CertiK raised this on
tokens::regtoken, but the identical unboundedname/descriptionpair, with the sameram_payer = sysiobilling, was also onchains::regchainandreserv::regreserve. Rather than fix one instance of a three-instance defect, the limits live once in a new shared header:contracts/sysio.opp.common/include/sysio.opp.common/registry_metadata.hpp—label_max_bytes = 32,description_max_bytes = 256(the latter matching the establishedtoken::issuememo bound).All three privileged registrations call
check_metadata()beforeemplace.reserv::oncrtreserveneeded different handling. It carries the same two strings from an outpost-side creator, but it is an OPP inbound dispatch handler — acheck()there rolls back the consensus-tipping delivery and stalls epoch advancement chain-wide (feedback_opp_handlers_never_throw,epoch-stall-is-fatal). It instead uses the non-throwingmetadata_exceeds_bounds()and joins the existing reject predicate alongsideinvalid_amountand the unlinked-creator case, releasing the creator's escrow through theRESERVE_CREATE_CANCELLEDflow that path already emits.One subtlety worth reviewing: the CANCELLED tombstone row is itself
sysio-billed and storesname/description, so rejecting an oversized name by writing it into the tombstone would have persisted exactly the state the bound prevents. The reject path clamps viatruncate_label/truncate_description, and the test asserts the stored name is 32 bytes rather than 33.The header now makes the split explicit: privileged abort-safe registrations
check; dispatch handlers ask and route.WNS-11 — depot identity not pinned to its code
Bootstrap invariant V3 (
docs/platform-bootstrap-config.md) is "exactly oneCHAIN_KIND_WIREchain, codeWIRE". Only the cardinality half was enforced on-chain, andis_depotis derived from the kind alone — so a registration could claim depot identity under any code (FAKE), with the code's validity resting entirely on the off-chain config validator.WNS-12
Removed the unused
sysiosystem::system_contractforward declaration fromsysio.token.hpp; it implied asysio.token→sysio.systemdependency that does not exist.Verification
contracts_unit_test(full,--sys-vm)unit_test(full,--sys-vm)plugin_testunit_testmatters here beyond the usual sweep:unittests/test_contracts.hpp.inloadssysio.token.wasmfrom the same build path, so the newis_account(issuer)check is live in those tests too. Everytoken::createcall site inunittests/usesaliceorsysio.token, both created in their fixtures.Compatibility with the harness bootstrap was checked before picking the limits:
RegistrySteps.tsregisters the WIRE chain under codeWIRE, and its longest metadata strings are a 23-byte name and a 42-byte description — comfortably inside 32/256.Reviewer note
The WNS-10 fix extends past CertiK's literal
regtokenscope to the three sibling registries plus theoncrtreservedispatch path. That was a deliberate call — same defect, same billing account — but it is the one part of this PR that is wider than the finding, so it is the part worth a second opinion.