Skip to content

fix: clamp remote sync ranges to the session namespace - #117

Open
jonaswre wants to merge 1 commit into
n0-computer:mainfrom
jonaswre:fix/clamp-sync-ranges-to-namespace
Open

fix: clamp remote sync ranges to the session namespace#117
jonaswre wants to merge 1 commit into
n0-computer:mainfrom
jonaswre:fix/clamp-sync-ranges-to-namespace

Conversation

@jonaswre

@jonaswre jonaswre commented Aug 2, 2026

Copy link
Copy Markdown

Description

All namespaces share one records table keyed (namespace[32], author[32], key[]),
so the query bounds are the only thing keeping documents apart. But get_range
(src/store/fs.rs) builds those bounds from the range endpoints in an incoming
RangeItem:

Ordering::Equal   => RecordsBounds::namespace(self.namespace)   // correct
Ordering::Less    => RecordsBounds::new(start, end)             // both remote
Ordering::Greater => from_start(&self.namespace, end)           // end remote
                     to_end(&self.namespace, start)             // start remote

Those endpoints are remote-controlled: RecordIdentifier is a raw Bytes with a
derived Deserialize, and Message::validate_limits counts parts and entries
without ever inspecting range. RecordsBounds::new is a passthrough and
RecordsRange::with_bounds applies no post-filter, so nothing downstream catches
it. get_fingerprint delegates to get_range and inherits the same flaw.

Impact. The computed diff is echoed straight back to the peer, and the
namespace pin and signature verification in validate_entry apply only to
incoming entries, never to the outgoing diff. Combined with accept_request,
which admits any authenticated peer for any namespace in the local sync set with
no per-namespace peer allowlist, a peer holding a legitimate ticket for one
document can read every document on the node — namespace ids, author keys,
full record keys, timestamps, content lengths, content hashes and both
signatures.

One frame is enough:

RangeItem {
    range:  Range { x: (0x00 * 64), y: (0xFF * 64) },   // spans the whole table
    values: vec![],
    have_local: false,
}

have_local: false makes the node compute diff = get_range(range), the empty
values filters nothing, and the diff is returned in the reply.
MAX_ENTRIES_PER_SYNC_MESSAGE caps a reply at 2048 entries, so a larger store is
paged by splitting — an inconvenience, not a mitigation.

That is worse than a pure metadata leak in two ways: Capability::Read is the
namespace id, so leaked ids can be redeemed for full sync sessions through the
front door; and leaked content hashes are directly fetchable from the blobs
store. Writes are unaffected — entry signatures bind both namespace and author,
and all three ingress paths verify before put.

Fix. Add RecordsBounds::clamp_to_namespace and apply it to every branch
that consumes remote input, including both sub-ranges of Greater, where
from_start/to_end pin only one side each. An empty intersection — what a
range naming only foreign namespaces clamps to — normalizes to a range selecting
nothing, since inverted bounds are not a valid redb query.

Breaking Changes

None. Normal traffic never exercised the gap: sessions start at
Range::new(x, x) (the Equal branch) and recursion only produces in-namespace
split points, which is presumably why this went unnoticed. Reconciliation is
unaffected — sync_big, sync_full_basic, sync_gossip_bulk and
sync_restart_node all pass.

Notes & open questions

  • Found during a security review of our fork,
    which imported iroh-docs v0.101.0. Present since at least
    refactor: renames iroh-sync & iroh-bytes (#2271) (2024-05-06) and possibly
    earlier under the iroh-sync name.
  • 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.
  • Written for edition 2021, so the emptiness check uses a nested if let rather
    than a let-chain.
  • Adjacent, deliberately not fixed here: RecordIdentifier(Bytes)
    deserializes with no length invariant, while to_byte_tuple/namespace()/
    author() slice 0..32 and 32..64. A remote-supplied identifier shorter
    than 64 bytes panics the docs actor thread, which runs with no catch_unwind.
    That is availability-only so I left it out of a security fix, but it shares the
    root cause and the same validation would close it. Say the word and I will add
    it here or in a separate PR.
  • The new test drives all three orderings with endpoints spanning the whole table
    and asserts nothing outside the session namespace comes back. It was confirmed
    to fail on unpatched main (leaking the other namespace via the Less branch).
  • 77 lib + 11 integration tests + doctests pass; cargo clippy --all-targets and
    cargo fmt clean.

Change checklist

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

🤖 Generated with Claude Code

All namespaces share one records table keyed `(namespace, author, key)`,
so the query bounds are the only thing keeping documents apart. But
`get_range` builds those bounds from the range endpoints in an incoming
`RangeItem`:

    Ordering::Equal   => RecordsBounds::namespace(self.namespace)   // correct
    Ordering::Less    => RecordsBounds::new(start, end)             // both remote
    Ordering::Greater => from_start(&self.namespace, end)           // end remote
                         to_end(&self.namespace, start)             // start remote

The endpoints are remote-controlled: `RecordIdentifier` is a raw `Bytes`
with a derived `Deserialize`, and `Message::validate_limits` counts parts
and entries without ever inspecting `range`. `RecordsBounds::new` is a
passthrough and `RecordsRange::with_bounds` applies no post-filter, so
nothing downstream catches it. `get_fingerprint` delegates to `get_range`
and inherits the same flaw.

The computed diff is echoed straight back to the peer, and the namespace
pin and signature verification in `validate_entry` apply only to
*incoming* entries, never to the outgoing diff. Combined with
`accept_request`, which admits any authenticated peer for any namespace in
the local sync set with no per-namespace allowlist, a peer holding a
ticket for one document can read every document on the node: namespace
ids, author keys, record keys, timestamps, content lengths, content
hashes and signatures.

One frame is enough -- `RangeItem { range: { x: 0x00 * 64, y: 0xFF * 64 },
values: vec![], have_local: false }`. `have_local: false` makes the node
compute the diff over those bounds, the empty `values` filters nothing,
and the result is returned. `MAX_ENTRIES_PER_SYNC_MESSAGE` caps a reply at
2048 entries, so a larger store is paged by splitting.

That is worse than a metadata leak in two ways: `Capability::Read` *is*
the namespace id, so leaked ids can be redeemed for full sync sessions
through the front door, and leaked content hashes are fetchable from the
blobs store. Writes are unaffected -- entry signatures bind both namespace
and author, and all three ingress paths verify before `put`.

Add `RecordsBounds::clamp_to_namespace` and apply it to every branch that
consumes remote input, including both sub-ranges of `Greater`, where
`from_start`/`to_end` pin only one side each. An empty intersection --
what a range naming only foreign namespaces clamps to -- normalizes to a
range selecting nothing, since inverted bounds are not a valid query.

Normal traffic never exercised the gap: sessions start at
`Range::new(x, x)` (the `Equal` branch) and recursion only produces
in-namespace split points, which is presumably why it went unnoticed.

Reconciliation is unaffected: the full suite passes, including
`sync_big`, `sync_full_basic`, `sync_gossip_bulk` and `sync_restart_node`
(77 lib + 11 integration + doctests). The new test drives all three
orderings with endpoints spanning the whole table and asserts nothing
outside the session namespace comes back; it was confirmed to fail on
unpatched main.

Adjacent, not fixed here: `RecordIdentifier(Bytes)` deserializes with no
length invariant while `to_byte_tuple`/`namespace()`/`author()` slice
`0..32` and `32..64`, so a remote identifier shorter than 64 bytes panics
the actor thread. Availability-only, but it shares the root cause and the
same validation would close it.
@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
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