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:
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) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user