Skip to content

id(chat-presence): heartbeat typing list is never deduplicated or bounded — one channel yields a 3.3 MB signed frame, defeating MAX_HEARTBEAT_CHANNELS #3991

Closed

Description

@dezren39

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) deduplicates channels and truncates it to MAX_HEARTBEAT_CHANNELS (64), but for typing it only filters to entries present in channels — 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's typing; heartbeats decoded from the wire never pass through new, 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 calls recv_presence.update(heartbeat). Signature verification does not help here: the sender signs its own bloated heartbeat with its own key, so verify() passes.
  • Laptop probe ~/git/fleet/probe-2026-11-01-laneA-presencetyping/ (verbatim chat_presence.rs, md5 68590664a175bd4a17937c28a2f0fb6b, matching sandbox main 70a595a; minimal stubs for groups::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_087 bytes (~3.3 MB frame naming one channel).
    • Control: new with 500 distinct channels → channels.len() = 64 (the cap works for channels).
    • A wire-shaped heartbeat with typing = vec![chan; 50_000] round-trips through encode_frame/decode_frame at full length and is accepted by update_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::new deduplicates typing (after filtering to channels), so typing.len() <= channels.len() for every constructed heartbeat.
  • PresenceTable::update_at applies 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_CHANNELS so the frame-size guarantee covers the whole heartbeat, not just channels.

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

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    priority/lowNice to have; do when convenient
    queue/queuedHas a queued task in the Supabase agent queue
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    effort/sSmall: expected under 2 hours of work
    on Nov 1, 2026
  2. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  3. added
    queue/claimedQueue worker lifecycle state
    work/assignedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 2, 2026
  4. 40 remaining items

  5. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 2, 2026
  6. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    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.

  7. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 2, 2026
  8. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  9. dezren39 commented on Nov 2, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/idPackage/app: id (pkgs/ or apps/ in this repo)effort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/lowNice to have; do when convenientsource/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions