Repository navigation
feat(api): bound transaction field occurrences before parsing - #10
Closed
waynercheung wants to merge 20 commits into
Closed
waynercheung wants to merge 20 commits into
waynercheung wants to merge 20 commits into
Conversation
waynercheung
force-pushed
the
feat/tx-admission-guard
branch
2 times, most recently
from
October 10, 2026 08:28
9481e6c to
6b4019f
Compare
waynercheung
force-pushed
the
feat/tx-admission-guard
branch
2 times, most recently
from
October 11, 2026 15:17
b1677c0 to
1d0c6fe
Compare
* fix(jsonrpc): handle null parameters consistently Validate nullable JSON-RPC arguments before they are dereferenced. Return stable protocol errors instead of jsonrpc4j's unhandled -32001 fallback, which exposes Java exception types. Validate eth_call's required transaction argument before its block selector. The double-null case now returns -32602 instead of -32600. Align eth_uninstallFilter lookup misses with geth: null, unknown and already removed IDs return false. Empty and non-hexadecimal strings also return false when no filter matches, without new format checks. Non-null lookup misses now return false instead of -32000. Use the value returned by remove to report whether a filter was removed and avoid the race between checking and removing the same entry. Remove the obsolete uninstall ItemNotFoundException declaration and error mapping. Java binaries remain compatible, but source callers catching that checked exception, or implementations still declaring it, may need adjustment when recompiled. Keep getFilterChanges and getFilterLogs on their existing lookup-miss errors, and preserve request-source precedence and filter isolation. Treat null block-detail flags as false and retain DTO defaults for explicitly null optional fields. Preserve filter wildcard semantics. Add binding, service, wire and VM execution regressions for null arguments, error contracts, filter removal, source isolation and concurrent uninstall results. * fix(jsonrpc): reject null required positional parameters Return invalid params for an explicit null filter ID in eth_uninstallFilter, eth_getFilterChanges and eth_getFilterLogs, and for a null fullTransactionObjects flag in eth_getBlockByHash and eth_getBlockByNumber. The block hash or selector is still validated first, and the flag is checked before any block lookup, so the result does not depend on whether the block exists. Unknown non-null filter IDs keep their existing results. This replaces the earlier null-as-false handling of the flag and the filter ID. go-ethereum v1.17.6 rejects null for these required arguments; earlier releases decoded it as a zero value. * refactor(jsonrpc): share the null check for required parameters Route the null checks for the ten required JSON-RPC parameter positions through one requireParam helper instead of repeating the same if/throw block at each site. The "invalid params" and "invalid filter request" messages are unchanged and are now both private constants in TronJsonRpcImpl. Remove the null check from the LogFilter constructor. In production code, LogFilter is only built through LogFilterWrapper from eth_getLogs and eth_newFilter, and both reject a null filter request before reaching it, so the constructor check and its unit test are dropped. Null filter requests remain covered at the service and wire level. Extract getBlockByHash(byte[]) and use it from ethGetBlockByHash and getBlockByJsonHash, so ethGetBlockByHash no longer inlines the block lookup. The validation order is unchanged. No behavior change. * test(jsonrpc): use the three-argument TronJsonRpcImpl constructor Adapt the tests added for null parameter handling to the constructor that now takes the Manager, and drop the removed setManager call. Tests built only from mocks pass a mocked Manager, and tests backed by the shared database pass dbManager, matching the existing tests.
…col#6985) Replace jsonrpc4j's unhandled -32001 fallback with a fixed -32603 response so unmapped exception types and raw messages no longer reach clients. Keep the documented -32001 contract for net_version and eth_chainId through explicit mappings with fixed message and data. Propagate VirtualMachineError, ThreadDeath, LinkageError, and TronError through the JSON-RPC boundary. Scan complete cause chains without allocating a visited set, detecting cycles without missing fatal causes. Classify Throwables escaping dispatch before logging or mapping them. Before rethrowing an Error, make a best-effort attempt to commit an empty HTTP 500 so the container does not render its default error page. Preserve the original error if cleanup fails, and leave an already committed response untouched. Route single requests through handleRequest because the servlet handle API catches Throwable. Catch RuntimeException, IOException, and Error at both dispatch boundaries, then propagate a classified fatal cause or return -32603 when a response is appropriate. This also covers non-fatal Errors from interceptor hooks. Discard partial dispatch output on failure. Recover each failed batch element without discarding earlier results or skipping later requests. Share internal-error construction across serialization, dispatch, and response-parsing failures. Count only the replacement when parsing fails, retaining existing overflow rules and the current single/batch handling of requests without an id. Log unmapped exceptions, chain-identity failures, and asynchronous log-query failures and interruptions at DEBUG with the Throwable, classifying fatal causes before logging the failures. Log batch request-serialization and response-parsing recoveries at DEBUG with the element index and the Throwable. Log the first escaped dispatch failure in each batch at ERROR with the Throwable; repeats use DEBUG with only the index and exception type. Restore interrupted status for asynchronous log queries. Reject Boolean, object, and array request IDs before dispatch with -32600 and id:null. Reject non-null scalar params with -32600, preserve valid IDs, and isolate invalid batch elements so their siblings run. Missing, null, array, and object params keep their existing semantics. Remove the obsolete request replay wrapper and HTTP status provider. Add resolver, servlet, embedded-Jetty, chain-identity, asynchronous failure, dispatch-contract, and request-envelope regression coverage. Exercise the real HTTP interceptor chain and observe commitment without performing it on the servlet's behalf. Test interceptor Errors with a real server and preserve the existing notification response-suppression rules with characterization tests. Add a package-private server injection seam for servlet tests without changing production initialization.
waynercheung
force-pushed
the
feat/tx-admission-guard
branch
from
October 13, 2026 04:46
1d0c6fe to
0aa4020
Compare
The node.rpc.maxRstStream and secondsPerWindow options defaulted to 0, which left the HTTP/2 RST_STREAM frame limit unset, and the limit was configured only when both values were positive. Give them sensible defaults, validate them at load time, and apply the limit unconditionally so the configuration behaves consistently. - NodeConfig: default maxRstStream/secondsPerWindow to 1000/5. In postProcess, reject negative values for either option and reject Integer.MAX_VALUE specifically for maxRstStream, which grpc-java treats as disabling the limit. Independently replace each zero with its default and log a warning. - RpcService: always configure maxRstFramesPerWindow from the config values; validation stays solely in NodeConfig, consistent with the neighboring maxConcurrentCallsPerConnection option. - reference.conf, docs/configuration.md and config/README.md: document the new defaults and the 0 fallback for compatibility. - Tests: pin the Java and reference defaults, preserve positive overrides, and verify PING at the RST limit followed by GOAWAY after one additional reset using a prebuilt HTTP/2 frame burst. Note: an explicit 0 now falls back to the default instead of leaving the limit unset, and maxRstStream = 2147483647 is rejected at startup.
protobuf-java materializes objects for repeated sub-message and unknown-field occurrences. A submitted Transaction containing many empty ret entries (2 bytes on the wire each) can therefore expand to tens of times its wire size on the heap during parseFrom. Existing transaction-level checks run after this allocation. The contract payload (Any.value) is also unpacked by TransactionCapsule.getOwner before signature verification, exposing its nested messages to the same amplification. Scan the wire bytes before parsing on the API entry points that accept a binary Transaction, and reject the request once it exceeds 50,000 field occurrences: - TransactionAdmissionGuard counts every tag with CodedInputStream, recurses into known sub-messages by descriptor, and recurses into the effective contract payload with the type getOwner would unpack, following protobuf merge semantics. Group encoding and nesting deeper than 32 are rejected. Block, database and P2P parsing do not use it. - For gRPC, every method whose request is a Transaction gets a request marshaller that reads the request with the inbound size bound, runs the guard and hands the same bytes to the original marshaller. A rejection returns a sentinel instead of throwing, and a handler wrapper closes the call with INVALID_ARGUMENT before the service method runs. This affects BroadcastTransaction, GetTransactionSignWeight, GetTransactionApprovedList, GetShieldTransactionHash and CreateCommonTransaction. - /wallet/broadcasthex runs the guard before Transaction.parseFrom. - Admission rejections use one fixed client message and one DEBUG line per rejected request, without a stack trace. Across the full mainnet history (15.79 billion transactions), the maximum observed count was 3,926 field occurrences. This is an API admission policy, not a consensus-validity limit: previously tolerated encodings and unusually complex transactions may now be rejected at these entry points. Consensus, execution and block validation are unchanged.
waynercheung
force-pushed
the
feat/tx-admission-guard
branch
from
October 13, 2026 13:54
0aa4020 to
6284602
Compare
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.
What does this PR do?
Adds an admission check that counts field occurrences in a submitted
Transactionbefore it is parsed, and rejects it once it exceeds 50,000. It applies to the API entry points that accept a binaryTransaction:BroadcastTransaction,GetTransactionSignWeight,GetTransactionApprovedList,GetShieldTransactionHash,CreateCommonTransaction(theWalletmethods whose request type isTransaction)/wallet/broadcasthexJSON transaction endpoints and gRPC methods with other request types are unchanged.
Changes:
TransactionAdmissionGuardscans the wire bytes withCodedInputStream(the decoder the generated parser uses) and counts every tag, known or unknown. It recurses into sub-messages by descriptor;bytesandstringfields are leaves, so large bytecode counts once. For eachContractit first resolves the effective type,type_urlandvalueunder protobuf merge rules (last occurrence wins, repeatedparameteroccurrences merge), then recurses into thatvaluewith the payload typeTransactionCapsule.getOwnerwould unpack. Avaluethat is never unpacked (overwritten, mismatchedtype_url, unknown type) stays a leaf. Group encoding and nesting deeper than 32 are rejected. The guard checks structure only; it does not validate field contents such as UTF-8 in strings.GuardedTransactionMarshallerreplaces the request marshaller of those gRPC methods. It reads the request with the same bound asmaxInboundMessageSize, runs the guard and passes the same bytes to the original marshaller, so parsing is unchanged for accepted requests.UNKNOWNand logs a SEVERE stack trace each time.AdmissionCheckedHandlerrecognizes the sentinel by identity and closes the call withINVALID_ARGUMENT; the service method is never invoked.RpcService.guardTransactionMethodsrebuilds each service definition with the guarded marshaller and handler for methods whose proto input type isTransaction. All services inRpcApiService,RpcApiServiceOnSolidityandRpcApiServiceOnPBFTare registered through it (on the SOLIDITY and PBFT ports, around the existing cursor interceptor), so aTransactionmethod added later is covered too. Only the five FullNodeWalletmethods match today.BroadcastHexServletruns the guard beforeTransaction.parseFrom.transaction rejected by admission check, and one DEBUG line per rejected request with the reason and no stack trace (the gRPC line also names the method and remote address).Block, database and P2P parsing do not use the guard: a valid block must never fail a local, non-consensus limit.
Why are these changes required?
protobuf-java materializes objects for repeated sub-message and unknown-field occurrences. A submitted
Transactioncontaining many emptyretentries (2 bytes on the wire each) can therefore expand to tens of times its wire size on the heap duringparseFrom(a 4 MiB request retained about 265 MiB in a local measurement). Emptyraw.contractandraw.authsentries and unknown fields with distinct numbers behave the same way. Existing transaction-level checks, such as the transaction size check and signature verification, run after this allocation.The outer parse keeps the contract payload (
Any.value) as bytes, butgetOwnerunpacks it before signature verification, exposing its nested messages to the same amplification. Checking only the outer message is not enough.Counting occurrences limits the structural complexity of the scanned schema, and with it the related object allocation, without field-specific rules. It is not an exact object count or heap size; the existing byte limits on requests stay in place.
Behavior changes (release note)
Transport and admission behavior was compared on the same inputs using a local differential harness with stubbed business handlers (gRPC) and a copy of the base servlet with a mocked
Wallet(HTTP). Production business-response mappings were additionally checked against source.INVALID_ARGUMENTwith descriptiontransaction rejected by admission check. Previously, inputs that failed to decode returnedUNKNOWN("Application error processing RPC") with a SEVERE log per request; inputs that decoded reached the service method and returned its result (OKwith an error code in the body for most methods, mostlyINTERNALforGetShieldTransactionHash)./wallet/broadcasthex:{"Error":"transaction rejected by admission check"}with HTTP 200. Previously a decodable request returnedresult/code/txid, and a malformed one returnedinternal server error.txid.HTTP request failed, broadcasthex) are replaced by one DEBUG line per rejected request without a stack trace. Exceptions raised inside service methods are unchanged.tron:grpc_service_latency_secondshas only anendpointlabel. Rejected calls are now closed through the intercepted call and are counted in this histogram; previously decode failures were closed by grpc directly and were not.Transaction(for exampleAny.type_url) still returninternal server error.maxInboundMessageSizestill returnsUNKNOWN. A corrupt gzip header still fails inside grpc before the marshaller runs.RESOURCE_EXHAUSTED.type_urldoes not itself cause an admission rejection; normal business validation is unchanged.Budget
TransactionAdmissionGuard.countTransactionwithout a limit (the counting code is identical to this PR; only a comment changed since the scan), both as stored and in a submission-shaped representation withretremoved. Withretremoved: p50 15, p99.99 43, maximum 3,926 (a contract deployment; 3,928 as stored), about 12.7x below the budget. 523 transactions exceed 1,000, all contract deployments; none exceed 5,000. The largest count for any other contract type is 198 (AccountPermissionUpdateContract). No transaction would be rejected by the guard, for budget or for structure. This shows the headroom over observed usage, not the largest valid transaction.Design notes
maxInboundMessageSize,CodedInputStream.setSizeLimit) and the recursion limit (setRecursionLimit, default 100). None of them bounds the number of field occurrences materialized from a message, which is what this guard counts. Worth revisiting if protobuf-java adds an element-count or allocation limit to parsing.This PR has been tested by:
New tests (59):
TransactionAdmissionGuardTest(28): counts and exact budget boundaries for each amplification shape; merge semantics (last type,type_urlandvaluewin, explicit empty overrides); payload recursion including shielded and multiple contracts; first-pass budget; malformed input; depth; concurrency; a corpus of valid transactions; the pinned budget value; no packed repeated scalar reachable fromTransactionor any contract type.AdmissionSupportTest(10): reason mapping, sentinel identity, bounded read edge cases.RpcServiceAdmissionTest(12): real loopback Netty server. Valid requests pass (plain and gzip); over-budget, malformed, invalid UTF-8 and corrupt gzip bodies that reach the marshaller getINVALID_ARGUMENTwithout reaching the service method and without a grpc SEVERE log; interceptor permits are released; cancellation;disabledApistill applies; message size limits unchanged.RpcServiceRegistrationAdmissionTest(4): the definitions actually registered by eachRpcServicesubclass; exactly five methods guarded on FullNode, none elsewhere; each guarded marshaller uses the production budget andmaxMessageSize.BroadcastHexServletAdmissionTest(5): fixed response without callingWallet, one DEBUG line per rejection without a stack trace.The new tests together with the existing suites they touch, run locally on JDK 17 (aarch64): 167 classes, 957 tests, all passing:
This includes the cursor interceptor wiring tests for the SOLIDITY and PBFT ports, which still pass with the guard applied outside the interceptor.
checkstyleMainandcheckstyleTestpass.Follow up
RESOURCE_EXHAUSTED.KnownLengthstream to restore grpc's array fast path, and simplify the bounded read; to be measured separately.Extra details
Design choices that reviewers may ask about:
TransactionAdmissionGuard.countTransactionis public so that sampling tools use exactly the production counting logic.TOO_LARGEis a fallback. grpc enforces the same inbound limit first, but the bound keeps the marshaller's allocation independent of transport settings.Transaction.parser()) so that its configuration and behavior are kept.isDebugEnabled()check in the handler avoids evaluating the method name, the remote address and the argument array when DEBUG is off.