Skip to content

frontend: deduplicate client-supplied address lists - #586

Open
LarryRuane wants to merge 1 commit into
masterfrom
fix/getaddressutxos-dedupe
Open

frontend: deduplicate client-supplied address lists#586
LarryRuane wants to merge 1 commit into
masterfrom
fix/getaddressutxos-dedupe

Conversation

@LarryRuane

@LarryRuane LarryRuane commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

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 one

zcashd sums over the list entries, so a repeated address is counted repeatedly. Verified against zcashd master: the same address listed twice returned 100365254620 zats against 50182627310 listed 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 multiplier

Same 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

  • Both lists are deduplicated before the backend call, keeping first occurrence, so the order the backend sees is unchanged.
  • For UTXOs the cap is applied to the raw list first, then dedupe, so the 10,000 limit remains a bound on input size and can't be slipped past with duplicates.
  • The dup check runs before 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.
  • GetTaddressTransactions and GetTaddressTxids carry a single address field, so they can't receive duplicates and are unaffected.

Scope

The UTXO change narrows the worst case; it does not close 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 — 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

TestGetTaddressBalanceDeduplicates uses 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.

TestGetAddressUtxosDeduplicates asserts the backend receives exactly the distinct addresses in first-occurrence order, and that an invalid address following duplicates still yields InvalidArgument.

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).

@LarryRuane
LarryRuane requested review from nullcopy and a lite review from Copilot August 4, 2026 23:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 GetAddressUtxos request 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.

Comment thread frontend/service.go
Comment thread CHANGELOG.md Outdated
@LarryRuane
LarryRuane force-pushed the fix/getaddressutxos-dedupe branch from e1cbafe to 1f8161b Compare August 5, 2026 17:04
@LarryRuane LarryRuane changed the title frontend: collapse repeated addresses in GetAddressUtxos frontend: deduplicate client-supplied address lists Aug 5, 2026
@LarryRuane
LarryRuane requested a review from pacu August 14, 2026 19:44
@LarryRuane

Copy link
Copy Markdown
Collaborator Author

@emersonian - please review

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>
@LarryRuane
LarryRuane force-pushed the fix/getaddressutxos-dedupe branch from 13598e0 to 7180ea5 Compare August 14, 2026 21:28
@LarryRuane

Copy link
Copy Markdown
Collaborator Author

Reviewed (though I am ostensibly the author, Claude wrote most of this); LGTM. I stepped through the new test, TestGetTaddressBalanceDeduplicates, in the debugger, and both the production and test code are working as expected.

LarryRuane pushed a commit that referenced this pull request Aug 14, 2026
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>
LarryRuane pushed a commit that referenced this pull request Aug 14, 2026
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>
LarryRuane pushed a commit that referenced this pull request Aug 14, 2026
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 pacu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK modulo the comment resolution

Comment thread frontend/service.go
@@ -886,22 +895,26 @@ func MempoolFilter(items, exclude []string) []string {
}

func getAddressUtxos(ctx context.Context, arg *walletrpc.GetAddressUtxosArg, f func(*walletrpc.GetAddressUtxosReply) error) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants