Skip to content

fix: enforce the per-request-type event mask, not always mask.get - #250

Open
jonaswre wants to merge 2 commits into
n0-computer:mainfrom
jonaswre:fix/enforce-per-request-event-mask
Open

fix: enforce the per-request-type event mask, not always mask.get#250
jonaswre wants to merge 2 commits into
n0-computer:mainfrom
jonaswre:fix/enforce-per-request-event-mask

Conversation

@jonaswre

@jonaswre jonaswre commented Aug 2, 2026

Copy link
Copy Markdown

Description

EventSender::request is generic over the four request types but reads a single
hardcoded field for all of them (src/provider/events.rs):

Ok(self.create_tracker((
    match self.mask.get {          // for get, get_many, push AND observe

mask.push, mask.get_many and mask.observe are never read anywhere in the
crate — the only mask reads are connected, get and throttle. All four entry
points in provider.rs have byte-identical bodies calling the same generic.

The consequence is that RequestMode::Disabled never takes effect for push.
RequestMode::None — what BlobsProtocol::new(&store, None) installs via
EventMask::DEFAULT — means "allow, don't notify", so the Disabled arm
returning ProgressError::Permission is unreachable for push under every
configuration I could construct.

That contradicts the documented contract in three places: the EventMask docs
("push requests are disabled by default, as they can write to the local store"),
DEFAULT and ALL_READONLY both setting push: RequestMode::Disabled, and
ALL_READONLY explicitly declining to ship a push-enabled constant because it
"would risk misuse".

Impact. A peer that knows a node's EndpointId — a public identifier
distributed in tickets and gossip — can dial the blobs ALPN and push blobs of its
choosing into the store, which the node then serves to anyone presenting the
hash. Bounding it honestly: content is BLAKE3-verified against the hash the
pusher names, so existing blobs cannot be forged, substituted or corrupted. The
impact is unauthorized write and content injection — the node becomes a content
host for attacker-chosen data under the operator's IP and identity.

Fix. Thread the applicable RequestMode into request as a parameter,
supplied by four typed wrappers next to the mask itself. Observe maps through a
new From<ObserveMode> for RequestMode.

Second commit: closing the gaps the first one opens

Reading the right field un-gates every config that had only ever set mask.get
and relied on the shared dispatch. Two of your own examples do exactly that,
so the first commit alone would ship a hole in the crate's own allowlist demo:

  • examples/limit.rs::limit_by_hash is get: Intercept, ..DEFAULT and only
    answers GetRequestReceived. Today get_many and observe are intercepted
    through the same field and refused (the example never answers them, the
    dropped oneshot fails rx.await??). After the first commit they read
    DEFAULT's None and are served unconditionally — a peer reads any blob by
    asking as a GetManyRequest, and probes bitfields with ObserveRequest. The
    example now answers all three with the same allowlist.
  • examples/random_store.rs builds on ALL_READONLY and gates pushes on
    --allow-push inside the PushRequestReceived arm. mask.push is Disabled
    there, so after the first commit the mask rejects before the handler ever
    runs and the flag silently does nothing.

I was wrong about observe — the original text here claimed its behaviour was
unchanged because ObserveMode has no Disabled variant. It is wrong in both
directions:

  • Observe currently reads mask.get, so get: Disabled did disable observe.
    After the change nothing can, and an observe response streams the local
    bitfield for a hash — exactly which byte ranges the node holds. Added
    ObserveMode::Disabled.
  • get: InterceptLog gave observe an Active tracker; Intercept -> Intercept
    yields Disabled, so completion updates stop being delivered. An observe
    request transfers no blobs, so completion is the only update it can ever emit,
    and the plain variants drop it. The conversion now maps through the *Log
    variants.

The test in the first commit was also weaker than it read.
allow_count_rx.changed() does not synchronize the denying node at all —
nothing observes it, and a disabled request type emits no event — so the
assertion was a single early sample that would pass on a broken mask. has is
additionally false for a partial import, so a rejection arriving mid-transfer
would leave attacker-chosen bytes in the store and still pass. It now asserts
BlobStatus::NotFound, repeatedly, to a deadline.

Breaking Changes

Behavioural, not API: push now actually requires a mask that enables it.

Your own tests were passing because of the bug — event_handler in
src/tests.rs used ALL_READONLY (which sets push: Disabled) and
two_nodes_push_blobs_fs/_mem asserted the pushed data arrived anyway. This PR
switches them to an explicitly push-enabled mask, which is the conscious act the
ALL_READONLY doc asks for. Any downstream relying on push under DEFAULT or
ALL_READONLY will see the same change, and I think that is the point of the
fix rather than a regression — but it is worth a release note.

Two smaller behavioural changes come with the second commit and are worth the
same note: ObserveMode gains a Disabled variant (additive, but the enum is
matched exhaustively inside the crate), and ObserveMode::Notify/Intercept
now deliver the completion update they used to get from mask.get's *Log
setting.

Notes & open questions

  • Found during a security review of our fork,
    which imported iroh-blobs v0.103.0. Traced to feat: Provider events refactor (#142)
    (2025-09-11) — it looks like a copy-paste slip while DRYing the four per-type
    gates into one generic, not a deliberate choice.
  • This is already public, which I regret: we fixed it in our fork and pushed
    before recognising it was inherited from upstream rather than introduced by us.
    Details are in fix: three access-control bugs found by a security review, plus a relay teardown bug holon-technologies/iroh#24. Happy to help with coordination.
  • get_many has the same shape: an operator setting get_many: Disabled
    alongside get: None currently gets get-many silently allowed. Same fix covers
    it.
  • Not addressed here, because it is outside this change: the pusher cannot
    observe a refusal at all. execute_push_sink calls recv.stop(0) and returns
    Ok(Stats::default()) unconditionally, and the provider handles each stream in
    a detached task, so a small push reports success while the receiver drops
    every byte. That makes the newly-enforced default silent data loss for anyone
    who was relying on BlobsProtocol::new(&store, None) accepting pushes. Worth
    a follow-up on the remote API's error contract; say the word and I'll open one.
  • Both new tests were confirmed to fail on unpatched main, for the right
    reason. 96 lib tests pass; cargo clippy --all-targets --all-features and
    cargo fmt clean on both commits.

Change checklist

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

🤖 Generated with Claude Code

`EventSender::request` is generic over the four request types but reads a
single hardcoded field for all of them:

    match self.mask.get {          // for get, get_many, push AND observe

`mask.push`, `mask.get_many` and `mask.observe` are never read anywhere in
the crate -- the only mask reads are `connected`, `get` and `throttle`.

The consequence is that `RequestMode::Disabled` never takes effect for
push. `RequestMode::None` -- what `BlobsProtocol::new(&store, None)`
installs via `EventMask::DEFAULT` -- means "allow, don't notify", so the
`Disabled` arm returning `ProgressError::Permission` is unreachable for
push under every configuration I could construct.

That contradicts the documented contract in three places: the `EventMask`
docs ("push requests are disabled by default, as they can write to the
local store"), `DEFAULT` and `ALL_READONLY` both setting
`push: RequestMode::Disabled`, and `ALL_READONLY` explicitly declining to
ship a push-enabled constant because it "would risk misuse".

So a peer that knows a node's `EndpointId` can push blobs of its choosing
into the store, which the node then serves to anyone presenting the hash.
Bounding it honestly: content is BLAKE3-verified against the hash the
pusher names, so existing blobs cannot be forged, substituted or
corrupted. The impact is unauthorized write and content injection -- the
node becomes a content host for attacker-chosen data under the operator's
identity.

Thread the applicable `RequestMode` into `request` as a parameter,
supplied by four typed wrappers next to the mask itself so the selection
lives with the data it reads. Observe maps through a new
`From<ObserveMode> for RequestMode`; it has no `Disabled` variant, so its
behaviour is unchanged beyond reading the right field.

Note the existing push tests were passing *because* of this:
`event_handler` used `ALL_READONLY`, which disables push, and
`two_nodes_push_blobs_fs`/`_mem` asserted the data arrived anyway. They
now use an explicitly push-enabled mask -- the conscious act the
`ALL_READONLY` doc asks for. Anyone relying on push under the default
mask will see the same change.

New tests push the same blob to a denying node and to a permitting one.
The pusher cannot observe a refusal (`execute_push_sink` stops its
receive stream and returns `Stats::default()`, and streams are handled in
detached tasks), so the assertion is that the blob does not land. The
permitting node is the control: it proves the push path works in that
setup, so the deny assertion cannot pass vacuously. Both were confirmed
to fail on unpatched main, for the right reason.

96 lib tests pass, clippy and fmt clean.
@n0bot n0bot Bot added this to iroh Aug 2, 2026
@github-project-automation github-project-automation Bot moved this to 🚑 Needs Triage in iroh Aug 2, 2026
Reading the right mask field un-gates every config that had only ever set
`mask.get` and relied on the old shared dispatch. Two shipped examples do
exactly that:

- `examples/limit.rs::limit_by_hash` is `get: Intercept, ..DEFAULT` and only
  answers `GetRequestReceived`. get_many and observe used to be intercepted
  through the same field and refused; on this branch they read `DEFAULT`'s
  `None` and are served unconditionally, so a peer reads any blob by asking
  as a `GetManyRequest` and probes bitfields with `ObserveRequest`. Answer
  all three request types with the same allowlist.
- `examples/random_store.rs` builds on `ALL_READONLY` and gates pushes on
  `--allow-push` inside the `PushRequestReceived` arm. `mask.push` is
  `Disabled` there, so the mask now rejects before the handler runs and the
  flag silently does nothing. Hand push to the handler that decides.

My claim that observe's behaviour was unchanged was wrong in both
directions. Observe used to read `mask.get`, so `get: Disabled` did disable
it, and nothing on this branch could — an observe response streams which
byte ranges of a hash the node holds, so add `ObserveMode::Disabled`. And
`get: InterceptLog` gave observe an `Active` tracker, while
`Intercept -> Intercept` yields `Disabled`: an observe request transfers no
blobs, so completion is the only update it can emit, and the plain variants
dropped it. Map through the `*Log` variants.

The new test was weaker than it read. `allow_count_rx.changed()` does not
synchronize the denying node at all — nothing observes it, and a disabled
request type emits no event — so the assertion was one early sample that
would pass on a broken mask. `has` is also false for a partial import, so a
rejection arriving mid-transfer would leave attacker-chosen bytes in the
store and still pass. Assert `BlobStatus::NotFound`, repeatedly, to a
deadline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 🚑 Needs Triage

Development

Successfully merging this pull request may close these issues.

1 participant