Skip to content

[security] Unrestricted events (Pin/Unpin/SetProfile) bypass membership check after kick #177

@intendednull

Description

@intendednull

Problem

crates/state/src/materialize.rs:290-292 documents that SetProfile, PinMessage, and UnpinMessage are "unrestricted (any member)" events, but no membership check is actually enforced:

//   SetProfile          — unrestricted (any member)
//   PinMessage,
//   UnpinMessage        — unrestricted (any member)

When a member is kicked, they are removed from state.members (line 221). However, if a late-arriving event from the kicked member is received after the kick is processed, it will be applied without checking membership:

  • SetProfile (line 472): Updates profiles without checking state.members
  • PinMessage (line 505): Pins messages without checking membership
  • UnpinMessage (line 514): Unpins messages without checking membership

Compare with RotateChannelKey (line 494-496) which correctly checks membership:

if !state.members.contains_key(&event.author) {
    return ApplyResult::Rejected(format!("author '{}' is not a member", event.author));
}

Severity

MEDIUM — kicked members can continue modifying server state (profiles, pins). Limited impact since they can't send messages or escalate privileges, but violates the principle that kicked members have zero authority.

Fix

Add the same membership check used by RotateChannelKey to the unrestricted event handlers:

EventKind::SetProfile { .. } | EventKind::PinMessage { .. } | EventKind::UnpinMessage { .. } => {
    if !state.members.contains_key(&event.author) {
        return ApplyResult::Rejected(format!("author '{}' is not a member", event.author));
    }
    // ... existing logic
}

Update the spec (docs/specs/2026-04-12-state-authority-and-mutations.md) to clarify that "unrestricted" means "any current member" not "anyone."

Locations

  • crates/state/src/materialize.rs:472-483 — SetProfile (no membership check)
  • crates/state/src/materialize.rs:505-511 — PinMessage (no membership check)
  • crates/state/src/materialize.rs:514-520 — UnpinMessage (no membership check)
  • crates/state/src/materialize.rs:494-496 — RotateChannelKey (correct pattern)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecurity

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions