frontend: deduplicate client-supplied address lists - #586
Conversation
There was a problem hiding this comment.
Pull request overview
This PR mitigates a DoS amplification vector in the GetAddressUtxos frontend by deduplicating repeated transparent addresses before calling the backend getaddressutxos RPC, keeping backend work proportional to the number of distinct addresses provided.
Changes:
- Deduplicate
GetAddressUtxosrequest addresses (preserving first-occurrence order) before validating and forwarding to the backend. - Add a unit test asserting distinct-address forwarding order and that invalid addresses are still rejected without contacting the backend.
- Document the behavioral change and remaining limitation in
CHANGELOG.md.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| frontend/service.go | Deduplicates address list before backend RPC call, preserving order and avoiding repeated backend work. |
| frontend/frontend_test.go | Adds test coverage for deduplication behavior and invalid-address rejection. |
| CHANGELOG.md | Notes the fix in Unreleased changelog entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
e1cbafe to
1f8161b
Compare
|
@emersonian - please review |
1f8161b to
13598e0
Compare
The transparent-address methods pass the client's address list to the backend unchanged, and the two backends treat repeats differently. zcashd's getaddressbalance sums over the list entries, so a repeated address is counted repeatedly: naming one address twice returns exactly twice its balance (verified against zcashd master: 100365254620 vs 50182627310 zats for the same address). zebrad parses the list into a HashSet before querying, so duplicates collapse and it reports the balance of the distinct addresses. The same request therefore returned different numbers depending on which backend lightwalletd was configured with, and the zcashd answer was wrong. getaddressutxos divides the same way: zcashd looks up each entry independently and returned 250 UTXOs for an address listed twice that yields 125 listed once, while zebrad returns the same set either way. Since repeating an address costs the caller nothing -- addresses are public, and neither a key nor any funds are needed to name one -- a single request could multiply the busiest address on the chain by maxTaddrsPerRequest against a zcashd backend. Deduplicate both lists before the backend call, keeping first occurrence so the order the backend sees is unchanged. This makes the balance correct rather than backend-dependent, and stops lightwalletd relying on the backend to bound work it can bound itself. The dup check runs before checkTaddress, so validation also happens once per distinct address rather than once per entry; an invalid address is still rejected, since its first occurrence reaches the check. The UTXO change narrows the worst case rather than closing it. StartHeight and MaxEntries are still response filters applied after the entire backend result has been fetched and unmarshalled, so a single named address with a large UTXO set is bounded by nothing here. That residual is the unremediated part of GHSA-x4m7-3gpp-xc36 and needs range and limit arguments pushed down into the backend RPC to close. GetTaddressTransactions and GetTaddressTxids take a single address, so they are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13598e0 to
7180ea5
Compare
|
Reviewed (though I am ostensibly the author, Claude wrote most of this); LGTM. I stepped through the new test, |
maxTaddrsPerRequest bounds GetTaddressBalanceStream, GetAddressUtxos and GetAddressUtxosStream, but not the unary GetTaddressBalance. The comment on the constant claimed that method was "already implicitly bounded by gRPC's MaxRecvMsgSize", giving "an equivalent bound". It is bounded, but not equivalently: the unary request carries its whole list in one protobuf message, so the 4MB default limit allows roughly 113,000 addresses (37 wire bytes each: 1 tag + 1 length + 35 characters), eleven times the cap the sibling methods enforce. The result is one method that forwards an order of magnitude more addresses to the backend than any other, for a reply of one int64. The deduplication in #586 does not help here, since distinct addresses cost the attacker nothing to generate. Apply the cap in getTaddressBalanceZcashdRpc, the choke point both the unary and streaming paths call. The stream keeps its own check, which rejects as the addresses arrive rather than after buffering them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maxTaddrsPerRequest bounds GetTaddressBalanceStream, GetAddressUtxos and GetAddressUtxosStream, but not the unary GetTaddressBalance. The comment on the constant claimed that method was "already implicitly bounded by gRPC's MaxRecvMsgSize", giving "an equivalent bound". It is bounded, but not equivalently: the unary request carries its whole list in one protobuf message, so the 4MB default limit allows roughly 113,000 addresses (37 wire bytes each: 1 tag + 1 length + 35 characters), eleven times the cap the sibling methods enforce. The result is one method that forwards an order of magnitude more addresses to the backend than any other, for a reply of one int64. The deduplication in #586 does not help here, since distinct addresses cost the attacker nothing to generate. Apply the cap in getTaddressBalanceZcashdRpc, the choke point both the unary and streaming paths call. The stream keeps its own check, which rejects as the addresses arrive rather than after buffering them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maxTaddrsPerRequest bounds GetTaddressBalanceStream, GetAddressUtxos and GetAddressUtxosStream, but not the unary GetTaddressBalance. The comment on the constant claimed that method was "already implicitly bounded by gRPC's MaxRecvMsgSize", giving "an equivalent bound". It is bounded, but not equivalently: the unary request carries its whole list in one protobuf message, so the 4MB default limit allows roughly 113,000 addresses (37 wire bytes each: 1 tag + 1 length + 35 characters), eleven times the cap the sibling methods enforce. The result is one method that forwards an order of magnitude more addresses to the backend than any other, for a reply of one int64. The deduplication in #586 does not help here, since distinct addresses cost the attacker nothing to generate. Apply the cap in getTaddressBalanceZcashdRpc, the choke point both the unary and streaming paths call. The stream keeps its own check, which rejects as the addresses arrive rather than after buffering them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pacu
left a comment
There was a problem hiding this comment.
utACK modulo the comment resolution
| @@ -886,22 +895,26 @@ func MempoolFilter(items, exclude []string) []string { | |||
| } | |||
|
|
|||
| func getAddressUtxos(ctx context.Context, arg *walletrpc.GetAddressUtxosArg, f func(*walletrpc.GetAddressUtxosReply) error) error { | |||
There was a problem hiding this comment.
@LarryRuane is it possible to send a GetAddressUtxosArg with no addresses? I don't think the code is guarding from that directly, it does so indirectly when the dedup work is done. I think if that can happen we shall return an "invalid argument" reply of some sort
The transparent-address methods pass the client's address list to the backend unchanged, and the two backends treat repeats differently.
getaddressbalance— a wrong number, not just a slow onezcashd sums over the list entries, so a repeated address is counted repeatedly. Verified against zcashd master: the same address listed twice returned
100365254620zats against50182627310listed once — exactly double.zebrad parses the list into a
HashSet<Address>before querying (methods.rs:3798), so duplicates collapse and it reports the balance of the distinct addresses.So the same request returned different balances depending on which backend lightwalletd was configured with, and the zcashd answer was wrong. This is a correctness fix, and it changes returned balances against a zcashd backend.
It is not a security issue: a client can only mislead itself this way, not inflate anyone else's balance.
getaddressutxos— a resource multiplierSame split. zcashd looks up each entry independently and returned 250 UTXOs for an address listed twice that yields 125 listed once; zebrad returns the same set either way. Repeating an address costs the caller nothing — addresses are public, and neither a key nor any funds are needed to name one — so against a zcashd backend a single request could multiply the busiest address on the chain by
maxTaddrsPerRequest(10,000).zebrad was never exposed to this. The point of fixing it in lightwalletd is that lightwalletd shouldn't depend on backend behavior to bound work it can bound itself — the same layering argument as validating input locally.
Details
checkTaddress, so validation happens once per distinct address rather than once per entry. An invalid address is still rejected, since its first occurrence reaches the check.GetTaddressTransactionsandGetTaddressTxidscarry a singleaddressfield, so they can't receive duplicates and are unaffected.Scope
The UTXO change narrows the worst case; it does not close it.
StartHeightandMaxEntriesare still response filters applied after the entire backend result has been fetched and unmarshalled, so a single named address with a large UTXO set is bounded by nothing here — and that case is unaffected by deduplication. That residual is the unremediated part of GHSA-x4m7-3gpp-xc36 and needs range/limit arguments pushed down into the backend RPC to close.Testing
TestGetTaddressBalanceDeduplicatesuses a stub that returns 1000 zats per list entry it receives — modelling zcashd — and asserts that four entries naming two distinct addresses yield 2000, not 4000.TestGetAddressUtxosDeduplicatesasserts the backend receives exactly the distinct addresses in first-occurrence order, and that an invalid address following duplicates still yieldsInvalidArgument.Mutation-tested: bypassing either dedupe fails its test, and a variant that deduplicates without preserving order fails 8/8 runs (six addresses are used specifically so map iteration can't match by chance).