fix: close the two findings the security review left open - #25
Merged
Conversation
`execute_push_sink` opened a bidirectional stream and immediately stopped
the receive half -- "we are not going to need this!" -- then returned
`Ok(Stats::default())` however the push went. That half is the only channel
a refusal travels on: the provider resets it with the reason code
(`handle_read_request_result` -> `writer.reset(e.code())`) when the event
mask, an interceptor or a rate limit turns the push down. Throwing it away
turned every refused push into a reported success, so a node upgrading to
an enforced `EventMask::DEFAULT` keeps reporting that pushes worked while
the receiver drops every byte.
Keep the stream and read it at the end: cleanly closed means accepted,
reset means refused, and the code says why. A provider from before this
existed drops its half without writing, which arrives as a clean end of
stream and reads as acceptance -- the same answer it gave before -- so
neither side needs the other to be new.
Pushes are now acknowledged, which is the point but is also a timing
change: `execute_push` returns once the blob has landed rather than once
the last byte reached the socket.
The test asserted store contents only, because the refusal was
unobservable; it now asserts both the refusal and the contents, and needed
no polling window once the push was acknowledged. Verified by neutralising
just the acknowledgement: the deny push goes back to `Ok(Stats { .. })` and
the test fails on it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Documentation for this PR has been generated and is available at: https://holon-technologies.github.io/iroh/pr/25/docs/krikos/ Last updated: 2026-08-05T07:27:33Z |
A frame the server could not decode returned an error from `handle_frame`, which ended `run_inner` and tore the connection down. The client is relaying for every peer it is talking to, so one unparseable frame cost all of that rather than one datagram -- and it is the exact inverse of what the send side already does, where an unencodable packet is dropped precisely because disconnecting over it would be out of proportion. `RecvError` already separates the two cases. A `Proto` error means this frame is unusable; the connection underneath is fine, so drop it, count it and carry on. A `StreamError` means there is nothing left to keep, so it stays fatal. Nothing bounds how many a client may send, deliberately: the read side is already rate limited, so a client sending malformed frames gets no more of this server than one sending valid ones. `recv_frames_invalid` is what makes it visible -- sustained growth there is a version mismatch or a bug on the client side. The new test writes the frame straight onto the byte sink, because a conforming client cannot produce one -- `Conn::start_send` refuses to encode an empty datagram, which is why only a buggy or hostile peer sends it -- and then checks that a Ping queued behind it is still answered. Verified by neutralising the split: the Ping is never answered because the session is already gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jonaswre
force-pushed
the
fix/push-ack-and-relay-frame-leniency
branch
from
August 5, 2026 07:25
7ecbf25 to
a0b0617
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The two findings from the max-effort review of #24 that were reported as
skipped, both because the fix looked larger than that PR's scope. Lookingat them properly, both are contained.
execute_push_sinkreported success for a refused pushexecute_push_sinkopened a bidirectional stream and immediately stopped thereceive half — "we are not going to need this!" — then returned
Ok(Stats::default())however the push went.That half is the only channel a refusal travels on. When the event mask, an
interceptor or a rate limit turns a push down, the provider resets it with the
reason code (
handle_read_request_result→writer.reset(e.code())). Theprovider side was already correct; the client threw the answer away before the
request had even been written.
The consequence lands on exactly the change #24 made: a node that upgrades to an
enforced
EventMask::DEFAULT(push: Disabled) keeps reporting that its pushesworked, while the receiver drops every byte.
Fix. Keep the stream and read it once the request is written: cleanly closed
means accepted, reset means refused, and the code says which. Compatible in both
directions — a provider from before this drops its half without writing, which
arrives as a clean end of stream and reads as acceptance, the same answer it gave
before.
Behaviour change worth a release note. Pushes are now acknowledged, so
execute_pushreturns once the blob has landed rather than once the last bytereached the socket.
One undecodable frame ended the sending client's relay session
handle_frameturned any decode error into a fatal one, endingrun_innerandtearing the connection down. The client is relaying for every peer it is talking
to, so one unparseable frame cost all of that instead of one datagram.
It is also the inverse of what the send side already does since 519a645: an
unencodable packet is dropped, precisely because disconnecting over it would be
out of proportion. The receiving client got that leniency; the sending one did
not.
Fix.
RecvErroralready separates the cases.Protomeans this frame isunusable while the connection underneath is fine — drop it, count it, carry on.
StreamErrormeans there is nothing left to keep, so it stays fatal.Nothing bounds how many a client may send, deliberately: the read side is already
rate limited, so a client sending malformed frames gets no more of this server
than one sending valid ones. The new
recv_frames_invalidcounter is what makesit visible; sustained growth there is a version mismatch or a client-side bug.
Tests
Both new assertions were verified against the defect rather than just against
green:
Ok(Stats { .. })and fails the test on it.Proto/StreamErrorsplit leaves the Ping queued behindthe bad frame unanswered — "Unexpected EOF, expected frame Pong" — because the
session is already gone.
The push test also lost the one-second polling window it needed while a refusal
was unobservable; the acknowledgement is the synchronisation now.
cargo fmt,cargo clippy --all-features --tests --examples, and the fullkrikos-relay and krikos-blobs suites pass. Determinism baselines re-based for the
one new test-only spawn, classified under the existing
#[cfg(test)]row.Change checklist
🤖 Generated with Claude Code