Parent: #108
Problem
crates/state/src/materialize.rs:383-390:
EventKind::Reaction { message_id, emoji } => {
if let Some(msg) = state.messages.iter_mut().find(|m| m.id == *message_id) {
msg.reactions
.entry(emoji.clone())
.or_default()
.push(event.author);
}
}
There is no check that event.author is already in the reactions list. The same author emitting two Reaction events for the same emoji on the same message produces duplicate entries, so clients display 👍 alice × 3 as 👍 alice, alice, alice, and state diverges across peers that saw different counts of duplicate events.
Exploit (reproduced)
Standalone binary: Alice posts a message, then emits two Reaction events with emoji = "thumbs". After materialize():
reactions['thumbs'].len() = 2
BUG CONFIRMED: same author double-reaction counted twice
Fix
Change the inner type from Vec<PeerId> to BTreeSet<PeerId>. This gives dedupe for free and preserves deterministic iteration order. Required changes:
crates/state/src/types.rs — change the field type on ChatMessage::reactions.
crates/state/src/materialize.rs:383-390 — change .push(event.author) to .insert(event.author).
- Anywhere that iterates
msg.reactions — BTreeSet::iter() already yields items in sorted order, so rendering may need adjustment if any code assumed insertion order.
willow-messaging / willow-client / willow-app / willow-web may have accessor code that consumes the old Vec shape — grep for .reactions and adjust.
Alternative (less invasive): keep the Vec<PeerId> shape and add a contains(&event.author) check before .push(). This is smaller but leaves the footgun in place for future authors of the code.
Recommend the BTreeSet change for safety.
Test
#[test]
fn same_author_duplicate_reaction_is_idempotent() {
// post a message, react twice with same emoji from same author,
// assert reactions[emoji].len() == 1
}
Regression test should mirror the exploit binary: two Reaction events from the same identity, same emoji, same target message, assert only one entry after materialization.
Parent: #108
Problem
crates/state/src/materialize.rs:383-390:There is no check that
event.authoris already in the reactions list. The same author emitting twoReactionevents for the same emoji on the same message produces duplicate entries, so clients display👍 alice × 3as👍 alice, alice, alice, and state diverges across peers that saw different counts of duplicate events.Exploit (reproduced)
Standalone binary: Alice posts a message, then emits two
Reactionevents withemoji = "thumbs". Aftermaterialize():Fix
Change the inner type from
Vec<PeerId>toBTreeSet<PeerId>. This gives dedupe for free and preserves deterministic iteration order. Required changes:crates/state/src/types.rs— change the field type onChatMessage::reactions.crates/state/src/materialize.rs:383-390— change.push(event.author)to.insert(event.author).msg.reactions—BTreeSet::iter()already yields items in sorted order, so rendering may need adjustment if any code assumed insertion order.willow-messaging/willow-client/willow-app/willow-webmay have accessor code that consumes the oldVecshape — grep for.reactionsand adjust.Alternative (less invasive): keep the
Vec<PeerId>shape and add acontains(&event.author)check before.push(). This is smaller but leaves the footgun in place for future authors of the code.Recommend the
BTreeSetchange for safety.Test
Regression test should mirror the exploit binary: two
Reactionevents from the same identity, same emoji, same target message, assert only one entry after materialization.