Audit H1: a group message is filed only if BOTH ends are members

Filing on the sender's word let a contact conjure a group thread we are
not in, and let anyone sharing a session with us — including a member we
removed — post into a group they had left. The relay's roster decides,
cached briefly so a poll pass carrying many group messages costs one
round trip rather than one each.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 08:35:32 -04:00
co-authored by Claude Opus 4.8
parent 4b4126707f
commit d1b0acdda6
@@ -585,7 +585,21 @@ class Session(
// ⚠️ A group message is filed under the GROUP, not the sender.
// Filing by sender scatters one conversation across as many threads
// as it has participants.
val thread = message.content.groupId
//
// 🔴 **Both memberships are checked first (audit H1).** Filing on
// the sender's word alone let a contact conjure a group thread we
// are not in, and let anyone we share a session with — including a
// member we REMOVED, whose 1:1 session outlives their membership —
// post into a group they do not belong to. The roster is the
// relay's; a group it does not list for us is not ours.
val gid = message.content.groupId
if (gid != null) {
val known = groupRoster(gid)
if (known == null || message.fromUser !in known) {
continue
}
}
val thread = gid
?.let(Vault::threadForGroup)
?: Vault.threadForUser(message.fromUser)
record(thread, message.content.text, mine = false,
@@ -716,6 +730,24 @@ class Session(
return posted
}
/**
* Members of a group we are in, or null if the relay does not list it for
* us (audit H1). Cached for a short window: a poll pass can carry many
* group messages and each must not cost a round trip.
*/
private var groupRosterCache: Pair<Long, Map<String, Set<Long>>>? = null
private fun groupRoster(gid: String): Set<Long>? {
val now = System.currentTimeMillis()
val cached = groupRosterCache
if (cached != null && now - cached.first < 30_000) return cached.second[gid]
val fresh = runCatching {
relay.listGroups().associate { it.id to it.memberIds.toSet() }
}.getOrNull() ?: return cached?.second?.get(gid)
groupRosterCache = now to fresh
return fresh[gid]
}
// -- Contact list (P1) ---------------------------------------------------
/**