Repository navigation
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
End-rey
left a comment
There was a problem hiding this comment.
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.
| exec.status = statusUndefined | ||
| exec.err = err | ||
|
|
||
| exec.log.Debug("received child with wrong parent header", zap.Stringer("child ID", obj), zap.Error(err)) |
There was a problem hiding this comment.
could be a WARN or ERROR log to me (the same as received child with empty parent, though)
There was a problem hiding this comment.
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.
f241ed6 to
bf71c31
Compare
| if parentHdr == nil { | ||
| return object.Object{}, nil, errors.New("missing parent header in object for part") | ||
| } | ||
| if err := verifyParentHeaderID(parentHdr, parent); err != nil { |
There was a problem hiding this comment.
This breaks GET by link ID: the parent header hashes to the parent's ID, not the requested link ID.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
73eeb1c to
779f3c8
Compare
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:
The proxy paths already do check this (
checkHeaderProtobufAgainstIDinpkg/services/object/server.go, used byget.go/head.gowhen 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$Objectfilters 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'scheckHeaderProtobufAgainstIDrelies on) and compares it with the requested ID, applied at each point a parent header is adopted: three in split assembly (V1initFromChild, V2processV2LastandprocessV2Link) 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
parentid is "known only to the minor child", so a part legitimately carrying no parent id at all stays acceptable — that tolerance lives inisChild, is covered byTestAssembleSplitPipelined'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
TestAssembleSplitRejectsForeignParentHeader, substituting a well-formed header for a different object and asserting the read fails without that header reaching the response.processV2Linkalready acted on the pre-existingisChildcheck, so its subtest stands as a no-regression guard rather than a reproducer.CalculateAndSetID.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 againstmaster.