Skip to content

fix: close the two findings the security review left open - #25

Merged
jonaswre merged 2 commits into
mainfrom
fix/push-ack-and-relay-frame-leniency
Aug 5, 2026
Merged

fix: close the two findings the security review left open#25
jonaswre merged 2 commits into
mainfrom
fix/push-ack-and-relay-frame-leniency

Conversation

@jonaswre

@jonaswre jonaswre commented Aug 5, 2026

Copy link
Copy Markdown

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. Looking
at them properly, both are contained.

execute_push_sink reported success for a refused push

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. 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_resultwriter.reset(e.code())). The
provider 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 pushes
worked, 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_push returns once the blob has landed rather than once the last byte
reached the socket.

One undecodable frame ended the sending client's relay session

handle_frame turned any decode error into a fatal one, ending run_inner and
tearing 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. RecvError already separates the cases. Proto means this frame is
unusable while the connection underneath is fine — drop it, count it, carry on.
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. The new recv_frames_invalid counter is what makes
it 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:

  • Neutralising only the push acknowledgement puts the deny-side result back to
    Ok(Stats { .. }) and fails the test on it.
  • Neutralising only the Proto/StreamError split leaves the Ping queued behind
    the 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 full
krikos-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

  • Tests if relevant.
  • All breaking changes documented.
  • Self-review.

🤖 Generated with Claude Code

`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>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

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
jonaswre force-pushed the fix/push-ack-and-relay-frame-leniency branch from 7ecbf25 to a0b0617 Compare August 5, 2026 07:25
@jonaswre
jonaswre merged commit 60eb5ac into main Aug 5, 2026
87 of 89 checks passed
@jonaswre
jonaswre deleted the fix/push-ack-and-relay-frame-leniency branch August 5, 2026 08:15
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.

1 participant