Skip to content

object/get: verify collected parent header against requested object ID - #4290

Open
fabwa wants to merge 5 commits into
nspcc-dev:masterfrom
fabwa:fix/get-parent-header-id-verification
Open

fabwa wants to merge 5 commits into
nspcc-dev:masterfrom
fabwa:fix/get-parent-header-id-verification

Conversation

@fabwa

@fabwa fabwa commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Split assembly and EC reconstruction build the response header for a virtual (parent) object from a parent header embedded in a part, child or link object received from another node, and emit it as the header of the object that was actually requested — without ever checking that the header's content hashes to that requested ID:

par := child.Parent()
...
exec.collectedHeader = par

The proxy paths already do check this (checkHeaderProtobufAgainstID in pkg/services/object/server.go, used by get.go/head.go when forwarding), so only the locally-assembling paths are affected.

A malicious or compromised container node — or an attacker who has placed themselves as a member of the same container, contacted before any honest node — can therefore answer with a different object's header. Conforming clients reject it, but the node's own deferred eACL pass (getStream.ValidateHeader → CheckEACL) evaluates $Object filters against those attacker-chosen fields, and internal consumers that disable verification accept them outright.

Fix

Add verifyParentHeaderID, which hashes the collected parent header (object.CalculateID — the same content-addressing the proxy path's checkHeaderProtobufAgainstID relies on) and compares it with the requested ID, applied at each point a parent header is adopted: three in split assembly (V1 initFromChild, V2 processV2Last and processV2Link) and six in EC (size-split by info / by linker, and the EC part read, in both the GET and RANGE variants).

Deliberately scoped to the parent headers the node itself turns into a response. Per the NeoFS API, a split part's parent id is "known only to the minor child", so a part legitimately carrying no parent id at all stays acceptable — that tolerance lives in isChild, is covered by TestAssembleSplitPipelined's "V2 accepts zero parent object ID" case (8760db1, #4041), and is untouched here: this check only ever runs on a header already selected as the authoritative one.

Test plan

  • Added TestAssembleSplitRejectsForeignParentHeader, substituting a well-formed header for a different object and asserting the read fails without that header reaching the response.
  • Confirmed against unpatched code that V1 and the V2 last-part path both leak the foreign header. processV2Link already acted on the pre-existing isChild check, so its subtest stands as a no-regression guard rather than a reproducer.
  • Existing split/EC fixtures built their parent objects with addresses picked upfront, unrelated to the content those objects actually carry — a shape an honest sender never produces, and which this check now correctly rejects. They are updated to derive the address from the content via CalculateAndSetID.
  • Two assertions in TestGetLocalOnly's "V2 split child without parent header" now observe the earlier, more specific rejection instead of the generic downstream "missing object header".
  • go build ./..., go vet ./... clean; go test ./pkg/services/object/... ./cmd/neofs-node/... passes in full, verified in a clean worktree against master.

@codecov

codecov Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.72340% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 32.80%. Comparing base (34d007f) to head (779f3c8).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
pkg/services/object/get/ec.go 33.33% 8 Missing ⚠️
pkg/services/object/get/assembly_v2.go 95.83% 1 Missing ⚠️
pkg/services/object/get/util.go 83.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4290      +/-   ##
==========================================
+ Coverage   32.70%   32.80%   +0.09%     
==========================================
  Files         673      673              
  Lines       41698    41743      +45     
==========================================
+ Hits        13638    13692      +54     
+ Misses      28060    28051       -9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@End-rey End-rey 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.

Shorten the comments to the API contract and non-obvious reasoning. The full problem description and implementation walkthrough belong in the PR description, rather than being repeated across functions and test fixtures.

Comment thread CHANGELOG.md Resolved
exec.status = statusUndefined
exec.err = err

exec.log.Debug("received child with wrong parent header", zap.Stringer("child ID", obj), zap.Error(err))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

could be a WARN or ERROR log to me (the same as received child with empty parent, though)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept it at Debug: every log in this file is Debug, including the empty-parent one you point at, and both fire on a response from a peer — at WARN a misbehaving node can flood the log on demand. Happy to raise both if you would rather see them.

@fabwa
fabwa force-pushed the fix/get-parent-header-id-verification branch from f241ed6 to bf71c31 Compare November 10, 2026 16:34
Comment thread pkg/services/object/get/ec.go Outdated
if parentHdr == nil {
return object.Object{}, nil, errors.New("missing parent header in object for part")
}
if err := verifyParentHeaderID(parentHdr, parent); err != nil {

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.

This breaks GET by link ID: the parent header hashes to the parent's ID, not the requested link ID.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, thanks — fixed in b54ad0a.

ResolveECPart returns the linker itself for a linker's own ID (!isParent + TypeLink), and getECPart then returns that linker rather than its parent header, so the check had no business running on that path. Moved below the linker branch: copyECObject already separates the two cases by ID, and a linker standing for a size-split parent is still verified in copySplitECObjectByLinker.

Added TestService_Get_EC_LinkerByOwnID, which failed with object not found before the move.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

One more thing from the same corner, so it is not a surprise later: the ranged path has no counterpart of the fix above.

copyECObjectRangeByRule turns any linker it receives into sizeSplitinkerError, with no GetID() != parent guard, so a range addressed to a linker's own ID is resolved against the size-split parent. Measured on a scene with two 10-byte children: without the check, a request for bytes [0,10) of the linker returns "AAAAAAAAAA" — the first child's payload — instead of the linker's own 80-byte link list. With the check it fails.

I left it failing rather than teaching that path to serve a linker's own range, which would be new behaviour, not a fix. TestService_Get_EC_RangeByLinkerIDIsNotServedFromParent in 73eeb1c pins it; say the word if you would rather have it serve the range properly or keep master's answer.

Fabian Wahle and others added 5 commits November 10, 2026 20:26
Split assembly and EC reconstruction build the response header for a
virtual (parent) object from a parent header embedded in a part, child
or link object received from another node, and emit it as the header of
the object that was actually requested - without ever checking that the
header's content hashes to that requested ID:

    par := child.Parent()
    ...
    exec.collectedHeader = par

The proxy paths already do check this (checkHeaderProtobufAgainstID in
pkg/services/object/server.go, used by get.go/head.go when forwarding),
so only the locally-assembling paths are affected. A malicious or
compromised container node - or an attacker who has placed themselves
as a member of the same container, contacted before any honest node -
can therefore answer with a different object's header. Conforming
clients reject it, but the node's own deferred eACL pass
(getStream.ValidateHeader -> CheckEACL) evaluates $Object filters
against those attacker-chosen fields, and internal consumers that
disable verification accept them outright.

Fix: add verifyParentHeaderID, which hashes the collected parent header
(object.CalculateID, the same content-addressing the proxy path's
checkHeaderProtobufAgainstID relies on) and compares it with the
requested ID, and apply it at each point a parent header is adopted -
three in split assembly (V1 initFromChild, V2 processV2Last and
processV2Link) and six in EC (size-split by info/by linker and the EC
part read, in both the GET and RANGE variants).

Deliberately scoped to the parent headers the node itself turns into a
response. Per the NeoFS API, a split part's `parent` id is "known only
to the minor child", so a part legitimately carrying no parent id at
all stays acceptable - that tolerance lives in isChild, is covered by
TestAssembleSplitPipelined's "V2 accepts zero parent object ID" case
(8760db1, nspcc-dev#4041), and is untouched here: this check only ever runs on
a header that has already been selected as the authoritative one.

Added TestAssembleSplitRejectsForeignParentHeader, substituting a
well-formed header for a different object and asserting the read fails
without that header reaching the response. Confirmed against unpatched
code that V1 and the V2 last-part path both leak the foreign header
(processV2Link already acted on the pre-existing isChild check, so its
subtest stands as a no-regression guard rather than a reproducer).

Existing split/EC fixtures built their parent objects with addresses
picked upfront, unrelated to the content those objects actually carry -
a shape an honest sender never produces, and which this check now
rejects. They are updated to derive the address from the content via
CalculateAndSetID. Two assertions in TestGetLocalOnly's "V2 split child
without parent header" now observe the earlier, more specific rejection
instead of the generic downstream "missing object header".

Signed-off-by: Fabian Wahle <fabian@nash.io>
Review learnings: comments cut to the contract, unexplained M29 reference
dropped, CHANGELOG entry shortened and numbered.

Signed-off-by: Fabian Wahle <fabian@nash.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The foreign header carried its own ID, so the claimed-parent-address
check rejected it before the hash verification ran: the V2 link case
passed without the fix. Make the header claim the requested object's ID,
which its content does not hash to, so only the hash check can tell the
two apart. Drop a panic from a test helper while here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Fabian Wahle <fabian@nash.io>
getECPart returns the linker itself, not its parent header, for a
size-split linker. Resolving an EC part for a linker's own ID yields that
linker, whose parent header is the size-split parent it links - so
verifying it against the requested ID made GET by linker ID fail with
"object not found". The caller already separates the two cases by ID.

Signed-off-by: Fabian Wahle <fabian@nash.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ranged GET has no counterpart of serving a linker requested by its own
ID: every linker received is taken for the size-split linker of the
requested object, so such a range was resolved against the size-split
parent and answered with that object's payload. It now fails.

Signed-off-by: Fabian Wahle <fabian@nash.io>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fabwa
fabwa force-pushed the fix/get-parent-header-id-verification branch from 73eeb1c to 779f3c8 Compare November 10, 2026 19:35

This branch has not been deployed

No deployments
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