Repository navigation
Description
Summary
pkgs/id chat presence bounds a heartbeat's channels list but not its typing list: typing is documented as a subset of channels ("The channels the node is currently typing in. A subset of channels", chat_presence.rs:107-109), yet neither the constructor, the table's receive path, nor the codec deduplicates or caps it. A heartbeat can carry an unbounded number of duplicate typing entries, so one signed heartbeat produces an arbitrarily large gossip frame and an arbitrarily large stored table entry — defeating MAX_HEARTBEAT_CHANNELS, whose stated purpose is to "bound the frame size and the work a single received heartbeat can make the table do" (chat_presence.rs:76-78).
Evidence (verified this run, main 70a595a)
PresenceHeartbeat::new(chat_presence.rs:138-161) deduplicateschannelsand truncates it toMAX_HEARTBEAT_CHANNELS(64), but fortypingit only filters to entries present inchannels— duplicates survive and there is no length cap.PresenceTable::update_at(chat_presence.rs:296-301), the receive path, applies the same filter-only treatment to a decoded heartbeat'styping; heartbeats decoded from the wire never pass throughnew, so the constructor's invariants would not save this path anyway.- The production receiver in
serve(commands/serve.rs:1575-1591) verifies the signature and then callsrecv_presence.update(heartbeat). Signature verification does not help here: the sender signs its own bloated heartbeat with its own key, soverify()passes. - Laptop probe
~/git/fleet/probe-2026-11-01-laneA-presencetyping/(verbatimchat_presence.rs, md5 68590664a175bd4a17937c28a2f0fb6b, matching sandbox main 70a595a; minimal stubs forgroups::GroupView/waiting::AuthorKey):PresenceHeartbeat::new(node, vec![chan], vec![chan; 100_000], ts)→channels.len() = 1,typing.len() = 100_000,encode_frame(...).len() = 3_300_087bytes (~3.3 MB frame naming one channel).- Control:
newwith 500 distinct channels →channels.len() = 64(the cap works for channels). - A wire-shaped heartbeat with
typing = vec![chan; 50_000]round-trips throughencode_frame/decode_frameat full length and is accepted byupdate_at.
Why it matters
Presence is an unauthenticated-reach, always-on gossip surface: any peer on the topic can get its own heartbeats accepted. The one size bound the protocol declares (64 channels) can be bypassed by three orders of magnitude through the sibling field, inflating every receiver's stored entry (retained until the TTL prune) and every re-gossiped frame. It is also a shape bug independent of size: duplicate typing entries mean the stored heartbeat's typing is not the set the docs promise, so any future consumer that iterates or counts it inherits the duplicates.
Acceptance criteria
PresenceHeartbeat::newdeduplicatestyping(after filtering tochannels), sotyping.len() <= channels.len()for every constructed heartbeat.PresenceTable::update_atapplies the same dedup/bound to decoded heartbeats, so a wire heartbeat with duplicate or excess typing entries is stored in bounded form (or refused), regardless of the constructor.- Regression tests: a heartbeat built or decoded with duplicate typing entries (e.g. 100_000 copies of one channel) is stored/encoded with at most one entry per channel; existing chat_presence tests keep passing.
- The bound is stated next to
MAX_HEARTBEAT_CHANNELSso the frame-size guarantee covers the whole heartbeat, not justchannels.
Distinct from: #2809 (the presence protocol itself, closed — this is a residual bound gap in its merged implementation), #3927 (abuse evaluator oversize, different module), #3950 (chat v1 core remainder — membership/store/rate, not presence frame bounds).
Activity
dezren39 commentedon Nov 1, 2026
🤖 Agent note · filed
typinglist is never deduplicated or bounded inPresenceHeartbeat::new(chat_presence.rs:138-161) or in the receive pathPresenceTable::update_at(:296-301), so one channel yields a 3.3 MB signed gossip frame and an unbounded stored entry, defeatingMAX_HEARTBEAT_CHANNELS(64). Probe:~/git/fleet/probe-2026-11-01-laneA-presencetyping/on the laptop (file md5 68590664a175bd4a17937c28a2f0fb6b) — new() with 100,000 duplicate typing entries keeps all 100,000 (frame 3,300,087 bytes); wire-decoded 50,000 duplicates accepted by update_at; control: 500 channels cap to 64.typingis deduplicated/bounded to the channel set in both the constructor andupdate_at, with regression tests per the issue's acceptance criteria.40 remaining items
dezren39 commentedon Nov 2, 2026
agent-note:v1 — state=parked; holder: repo-worker-code (OpenChamber scheduled lane); task_id: 6277; lane: aea12c82-c5af-4e5a-9b66-f8e360f87177; at: 2026-11-02T20:45:25Z. Parked for 20 minutes and ending this launch because CI remains pending on PR #4510. The PR is Drew-authored; do not touch or merge it. Preserve the existing PR/worktree. Resume after owner handoff or resolution, then re-read the checks fresh; merge only if green and all criteria are met.
dezren39 commentedon Nov 2, 2026
🤖 Agent note · picked up
fix(id): deduplicate and bound typing claims in presence heartbeats (#…
dezren39 commentedon Nov 2, 2026
🤖 Agent note · done