fix: F-2026-18146 | [Dual Defense] Unbounded Universal Payload Size and Unbounded gRPC Response in Universal Validator - #349
Merged
Conversation
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.
F-2026-18146 — Unbounded Universal Payload Size / Unbounded gRPC Response in the Universal Validator
Severity: Info. Two defences, one PR — a hard size limit on the chain side, and an explicit
receive bound on the validator side.
Why a flat size cap and not a gas price
MsgExecutePayloadandMsgVoteInboundare fee exempt (app/txpolicy/gasless.go), so nothingcharges the submitter for the bytes it puts into a block, into consensus state, and into every node's
memory. On a fee-exempt path a price curve is not a defence — the size has to be bounded outright.
The number is flat rather than derived from the Solidity
UniversalPayloadstruct because that structis variable length; a flat number is auditable and stable. 128 KiB, deliberately the same limit
F-2026-18140 (#346) applies to an ed25519 raw message, so there is one payload size limit to reason
about, not two. Different code paths, same number.
1 — Core:
MaxUniversalPayloadBytes = 128 * 1024x/uexecutor/types/constants.go. Two small helpers apply it:(*UniversalPayload).ValidateSize()— the proto-serialized size of the payload(
Size()), so every field is covered, not justdata.ValidatePayloadBlobSize(field, blob)— the hex blobs that carry a payload on the wire(
raw_payload,verification_data).Applied at each point a payload enters:
MsgExecutePayload.ValidateBasicUniversalPayload.ValidateBasic) +verificationDataKeeper.ExecutePayloadstep 1verificationData, before any EVM workInbound.ValidateBasicraw_payload,verification_data, embeddeduniversal_payloadKeeper.VoteInboundInbound.ValidateForExecutionUniversalPayload.ValidateBasic), post-decodeKeeper.RevertStuckInboundInbound.ValidateBasicThe keeper checks are not redundant belt-and-braces. A universal validator broadcasts its votes wrapped
in
authz.MsgExec(universalClient/pushsigner/pushsigner.gowrapWithAuthZ).authz.MsgExechas noValidateBasicof its own, so baseapp'svalidateBasicTxMsgsdoes not reach the inner msg at CheckTx;the inner
ValidateBasicruns later, inside authz'sExecmsg server, during delivery. Putting the capin the keeper as well makes it hold for every caller of the method regardless of which msg route, or
which SDK version's authz behaviour, carried the payload in.
Nothing in the tree comes close to 128 KiB — the largest hex literal anywhere is ~23 kB of characters.
No in-tree payload or fixture changes behaviour. One existing assertion is affected, see the
F-2026-18798 note below.
2 — UV: an explicit gRPC receive bound
universalClient/pushcore/pushCore.gobuilt its dial options with transport credentials only —no receive-size limit anywhere in
universalClient/(zero matches forMaxCallRecvMsgSize/MaxRecvMsgSizebefore this change). The client was running on grpc-go's implicit 4 MiB default:a bound nobody chose and that is tied to nothing this client asks for.
maxPushCoreRecvMsgSize = 8 MiB, sized off the largest poll rather than picked:GetAllPendingOutboundsasks for
pendingOutboundPageSize(1000) entries and gets the matching outbounds back, so 2 x 1000 rowsin one response. A row costs roughly a kilobyte today (the in-tree comment already says so), so a 4 KiB
per-row budget gives 4x headroom and lands on 8 MiB. Above the 4 MiB the client has been running on, so
nothing that works today starts failing; low enough that a hostile or broken endpoint cannot stream an
unbounded body into the validator. An oversized response now fails as
ResourceExhausted: received message larger than max (N vs. 8388608)against a bound this repo chose.Tests
x/uexecutor/types/payload_size_test.go— a payload whoseSize()is exactly 131072 is accepted;131073 is rejected with
universal payload too large: 131073 bytes exceeds the 131072 byte limit.Same boundary for
raw_payload,verification_dataand the embeddeduniversal_payload.test/integration/uexecutor/payload_size_cap_test.go— over the realauthz.MsgExecroute, andstraight into the keeper. Asserts state before the error: an oversized vote writes no
PendingInboundsentry and creates noUniversalTx; a vote at the cap is recorded with its full128 KiB
raw_payload.universalClient/pushcore/recv_size_test.go— a real gRPC server on a loopback port. A 5 MiB response(above grpc-go's default, below ours) succeeds, which only holds because the dial option is set; a
response one byte past the bound fails as
ResourceExhaustednaming 8388608.Mutation check
Reverted both caps (
ValidateSizeandValidatePayloadBlobSizetoreturn nil; the dial optionremoved), kept the tests:
An error is expected but got nil.Expected error with "collections: not found" in chain but got nil— i.e. without the cap the oversized inbound is written intoPendingInbounds, which isthe state-bloat vector this finding is about; and
ExecutePayloadwalked on into a realgetUEAForOrigincontract call carrying the oversized payload.received message larger than max (5242901 vs. 4194304)— the implicitdefault, demonstrating the exact gap.
Restored, all green.
Interaction with F-2026-18798 (already on
audit-fixes)18798 added an 80-character length cap on the uint256 decimal fields, with a regression that feeds a
3,000,000-digit
valuethroughUniversalPayload.ValidateBasicand asserts the per-field message.A 3M-digit field is a >128 KiB payload, so the payload cap now fires first and shadows that one
assertion. Both rejections are O(1) on
len()— nobig.Intparse — which is the property that testexists to hold, so its timing assertion is untouched and now accepts either rejection reason. The
per-field message itself stays pinned by
TestValidateUint256String_Boundsand byTestInboundAndOutbound_RejectHugeDecimalFast, neither of which is size capped.Worth saying out loud for the Hacken reply: the 128 KiB cap clamps the worst case 18798 was priced
against. Through a universal payload the largest reachable decimal field is now ~128 K digits, not
3 M, so 18798's quoted parse timings at 900 K+ digits are no longer observable on that path. They
remain reachable through
Inbound.AmountandOutboundTx, which this cap does not cover.Residual, deliberately not fixed here
MsgMigrateUEA.Signatureis still unbounded. It rides the same fee-exempt list but carries a migrationpayload, not a universal payload; folding it in would widen this PR past the finding. Worth a follow-up
decision.
Test run
go test -mod=readonly -p 1 -count=1 -tags="ledger test_ledger_mock test" ./x/... ./test/integration/...— 14 packages ok, 1 failing:
test/integration/uexecutor, onTestRevertStuckInbound_PendingUnreachable_ThresholdMet_CreatesRevertOutboundandTestRevertStuckInbound_PendingUnreachable_BelowThreshold_Accepted.Both are pre-existing and unrelated. Checked out at
7473e484with none of this branch's changes,the same two fail identically.
setupRevertStuckInboundnever seeds the UniversalCore gas oracle, sogetOutboundTxGasAndFeeson0x…C0reverts, the revert outbound landsABORTED(4) instead ofPENDING(1) and is never indexed inPendingOutbounds. Nothing in this PR touches outbound creationor gas fees.
go test -mod=readonly -p 1 -count=1 ./universalClient/...— 24 packages ok, 0 failing.