Skip to content

Guard queryByID against oversized peer responses to prevent node crash - #2153

Open
Effi-S wants to merge 2 commits into
mainfrom
iss2055
Open

Guard queryByID against oversized peer responses to prevent node crash#2153
Effi-S wants to merge 2 commits into
mainfrom
iss2055

Conversation

@Effi-S

@Effi-S Effi-S commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #2055

Summary

queryByID unmarshals a single peer's raw chaincode-query response directly into values, then ranges over it while indexing the original keys slice by position — with nothing validating that the response returned exactly len(keys) elements. This function runs inside a goroutine with no recover() anywhere in the call chain, so an oversized response crashes the whole process, not just the request.

Where

token/services/network/fabric/lookup/deliveryqs.go:109-132:

values := make([][]byte, 0, len(keys))
err = json.Unmarshal(res, &values)
...
for i, value := range values {
    ...
    notFound = append(notFound, keys[i])   // deliveryqs.go — panics if len(values) > len(keys)
    ...
}

res is the raw response from a single peer's chaincode query (ChannelStateQuerier.QueryStatesChannel.Chaincode(ns).Query(...).Query()).

Impact

queryByID runs inside a goroutine spawned by QueryByID (go q.queryByID(...)) with no recover() anywhere in the call chain. A byzantine, buggy, or simply out-of-sync peer that answers a QueryStates request with more elements than were requested for a given namespace drives i past the end of keys, panicking that goroutine — which is unrecovered and therefore crashes the entire process, not just the one request. This is a full-availability attack surface reachable by anything capable of influencing or replacing a single peer's chaincode-query response.

Fix

Two independent, complementary defenses:

A — Validate the response length before the loop. Never trust the peer to return the right number of values. A mismatch is treated like the other failure cases already handled in this function (bad marshal, query error): log it and fall back to the slower block scan instead of trusting the response.

if len(values) != len(keys) {
    logger.Errorf("peer returned %d values for %d keys in ns [%s]; falling back to block scan",
        len(values), len(keys), ns)
    startDelivery = true
    continue // treat as a per-namespace failure (=> fall back to the slow block scan)
}

B — Wrap the goroutine body in recover() (defense in depth). Even after Fix A, any future unforeseen panic in this background path must degrade to a failed request rather than crashing the node.

go func() {
    defer func() {
        if r := recover(); r != nil {
            logger.Errorf("recovered from panic in queryByID: %v", r)
        }
    }()
    q.queryByID(ctx, keys, ch, startingBlock, evicted)
}()

Tests

TestQueryByID_OversizedResponse in deliveryqs_test.go feeds a crafted response with more values (2) than keys requested (1). Before the fix this panics; after Fix A it delivers nothing for the oversized namespace and falls back to the block scan:

querier := &fakeQuerier{results: map[driver.Namespace]querierResult{
    "ns1": {raw: values(t, []byte("v1"), []byte("v2-unexpected"))},
}}
scanner := &fakeScanner{}

ch, err := newQuery(querier, scanner).QueryByID(t.Context(), 100, evictedFor(map[driver.Namespace]driver.PKey{
    "ns1": "k1",
}))
require.NoError(t, err)
assert.Empty(t, drain(ch))
assert.True(t, scanner.called, "the oversized response must fall back to the block scan")

@Effi-S Effi-S changed the title Added Oversized Response Guard and goroutine defer wrap Guard queryByID against oversized peer responses to prevent node crash Aug 5, 2026
@Effi-S Effi-S added this to the Q3/26 milestone Aug 5, 2026
@Effi-S Effi-S added bug Something isn't working go Pull requests that update go code security hardening network-driver labels Aug 5, 2026
@Effi-S Effi-S self-assigned this Aug 5, 2026
@Effi-S
Effi-S force-pushed the iss2055 branch 4 times, most recently from da28c96 to 7dbd051 Compare August 5, 2026 14:02
@Effi-S
Effi-S requested review from AkramBitar and adecaro August 5, 2026 14:37
@Effi-S

Effi-S commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@adecaro , Any suggestions regarding these changes?

@Effi-S
Effi-S force-pushed the iss2055 branch 2 times, most recently from 24c31e1 to 9127cfc Compare August 6, 2026 11:50
Signed-off-by: Effi-S <effi.szt@gmail.com>
Signed-off-by: Effi-S <effi.szt@gmail.com>

@atharrva01 atharrva01 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.

hi @Effi-S , I spent some time in this function recently (#1426, #1999) so I read this one closely. The panic looks real to me: the loop ranges values but indexes keys, and since for ns, keys := range keysByNS shadows the outer parameter, the check is comparing against that namespace's keys, which is the right thing to compare against. Using != rather than > also seems right, since a short response would index in range but pair values with the wrong keys.

One thing I checked in case it was worth widening the PR: the finality twin at network/fabric/finality/deliveryqs.go does not have this bug, it walks the key set and fetches per txID with no positional indexing. So Fix A really is specific to the lookup path.

Ran the package at -count=2 -race, green. Three notes below, all non-blocking, and the first is more of a question than a suggestion.

// this goroutine's call chain has a recover().
defer func() {
if r := recover(); r != nil {
logger.Errorf("recovered from panic in queryByID: %v", r)

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.

A question rather than a request, since I might be weighing this differently to you.

When the recover fires, queryByID's own defer close(ch) has already run during unwinding, so the caller gets a closed empty channel and no error. ScanFromBlock is never reached, so those keys get no block scan fallback either. As far as I can tell the listener then just never fires for them, which looks like a silent permanent non-resolution rather than a failed request.

Your own test pins this, which is what made me notice: TestQueryByID_PanicIsRecovered asserts assert.False(t, scanner.called) (line 274).

A crash is clearly worse, so this is still an improvement. But the Fix A path is careful to set startDelivery and degrade to the scan, and it might be nice if the recovered path landed somewhere similar rather than closing empty. I appreciate that is awkward given the inner defer has already closed the channel, so possibly not worth it here.

ch := make(chan []KeyInfo, len(keys))
go q.queryByID(ctx, keys, ch, startingBlock, evicted)

go func() {

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.

Not for this PR, just noting it while it is in view: the finality side has the same bare go q.queryByID(...) with no recover (network/fabric/finality/deliveryqs.go:57). Fix A's bug genuinely is not there, but if an unrecovered panic in one of these background goroutines counts as a node-crash surface, the argument for Fix B seems to apply to that one too.

Happy to open an issue for it so it does not widen this PR, if you think it is worth tracking.

querier := &fakeQuerier{results: map[driver.Namespace]querierResult{}}
scanner := &fakeScanner{}

// An empty listener slice makes the namespace lookup index out of range and panic.

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.

Minor, and possibly not worth changing: this leans on slices2.GetAny panicking on an empty slice, so the test is tied to how that FSC helper behaves on empty input rather than to anything in this file.

A fakeQuerier whose QueryStates panics would pin Fix B through one of the interfaces the test already controls, and would keep working regardless of what the helper does. The current version does exercise the recover today, so this is only about how it ages.

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

Labels

bug Something isn't working go Pull requests that update go code hardening network-driver security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

network-driver: lookup queryByID panics the goroutine (crashing the process) on an oversized peer response

3 participants