fix: clamp remote sync ranges to the session namespace - #117
Open
jonaswre wants to merge 1 commit into
Open
Conversation
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.
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.
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 incomingRangeItem:Those endpoints are remote-controlled:
RecordIdentifieris a rawByteswith aderived
Deserialize, andMessage::validate_limitscounts parts and entrieswithout ever inspecting
range.RecordsBounds::newis a passthrough andRecordsRange::with_boundsapplies no post-filter, so nothing downstream catchesit.
get_fingerprintdelegates toget_rangeand inherits the same flaw.Impact. The computed diff is echoed straight back to the peer, and the
namespace pin and signature verification in
validate_entryapply only toincoming 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:
have_local: falsemakes the node computediff = get_range(range), the emptyvaluesfilters nothing, and the diff is returned in the reply.MAX_ENTRIES_PER_SYNC_MESSAGEcaps a reply at 2048 entries, so a larger store ispaged by splitting — an inconvenience, not a mitigation.
That is worse than a pure metadata leak in two ways:
Capability::Readis thenamespace 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_namespaceand apply it to every branchthat consumes remote input, including both sub-ranges of
Greater, wherefrom_start/to_endpin only one side each. An empty intersection — what arange 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)(theEqualbranch) and recursion only produces in-namespacesplit points, which is presumably why this went unnoticed. Reconciliation is
unaffected —
sync_big,sync_full_basic,sync_gossip_bulkandsync_restart_nodeall pass.Notes & open questions
which imported iroh-docs v0.101.0. Present since at least
refactor: renames iroh-sync & iroh-bytes (#2271)(2024-05-06) and possiblyearlier under the
iroh-syncname.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.
if letratherthan a let-chain.
RecordIdentifier(Bytes)deserializes with no length invariant, while
to_byte_tuple/namespace()/author()slice0..32and32..64. A remote-supplied identifier shorterthan 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.
and asserts nothing outside the session namespace comes back. It was confirmed
to fail on unpatched
main(leaking the other namespace via theLessbranch).cargo clippy --all-targetsandcargo fmtclean.Change checklist
🤖 Generated with Claude Code