fix(security): Tier C F-01/F-02/F-03 insider resource-exhaustion caps

A room ticket holder is an authenticated insider; signatures only prove
keypair ownership, not a distinct human. Previously such a member could
exhaust a victim's memory/tasks/dials without bound. Add caps + dedup at
the gossip/core/UI boundaries (no wire/protocol change, no new deps):

F-01 (gossip): cap the roster at MAX_ACTIVE_PEERS (32) — new authors are
rejected when full, existing peers' updates always pass; sanitize each
announced EndpointAddr (<=8 addrs, relay-URL <=256 bytes, drop Custom);
replace (set_endpoint_info) instead of unioning attacker address history.

F-02 (core): gate chat image auto-fetch — only roster authors qualify,
(author, attachment_id) is deduped, and a 4-permit pool bounds concurrent
detached fetch tasks (RAII AutoFetchGuard releases permit + dedup marker).
Chat text is still shown (already sanitized); the user-initiated "Save"
fetch is unchanged. Non-roster sock-puppet chat can no longer spawn tasks.

F-03 (app): replace the unbounded AVATAR_HANDLE_CACHE map with a bounded,
byte-equality-keyed LRU (avatar::ByteLru, cap 64) — fixes both unbounded
growth from an endless stream of distinct valid avatars and the prior
64-bit-hash-collision-shows-wrong-avatar bug.

Pure seams (sanitize_endpoint_addr, admit_into_roster, should_auto_fetch,
ByteLru) + 6 adversarial/unit tests. 413 lib tests, clippy --all-targets
clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 15:10:08 -04:00
co-authored by Claude Opus 4.8
parent 7349744d16
commit 5c11947bd7
4 changed files with 364 additions and 35 deletions
+19 -14
View File
@@ -5526,30 +5526,35 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage>
.into()
}
/// Maximum distinct avatar images we keep handles for. Each avatar is bounded to
/// 48 KiB / 256×256 at ingest, so a 64-entry LRU caps this cache at a few MB
/// regardless of how many distinct avatars peers publish over time (Tier C F-03).
const AVATAR_CACHE_CAP: usize = 64;
thread_local! {
/// Cache of avatar image handles, keyed by a hash of the PNG bytes, so the
/// SAME `image::Handle` (and thus the same GPU texture id) is reused across
/// Bounded cache of avatar image handles, keyed by PNG content, so the SAME
/// `image::Handle` (and thus the same GPU texture id) is reused across
/// redraws. `image::Handle::from_bytes` mints a fresh *unique* id on every
/// call, so building handles inline in `view()` made iced re-upload the
/// texture on every repaint — including the redraws fired on each mouse move —
/// which showed up as constant flicker. Lives on the (single) UI thread.
static AVATAR_HANDLE_CACHE: std::cell::RefCell<HashMap<u64, iced::widget::image::Handle>> =
std::cell::RefCell::new(HashMap::new());
/// which showed up as constant flicker. A peer can publish an unbounded stream
/// of distinct valid avatars over a session, so the cache is an LRU (bounded +
/// byte-equality keyed) rather than a plain map (Tier C F-03). Lives on the
/// (single) UI thread.
static AVATAR_HANDLE_CACHE:
std::cell::RefCell<crate::avatar::ByteLru<iced::widget::image::Handle>> =
std::cell::RefCell::new(crate::avatar::ByteLru::new(AVATAR_CACHE_CAP));
}
/// A stable image handle for these exact PNG bytes (cached by content hash), so
/// it keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
/// A stable image handle for these exact PNG bytes (cached by content), so it
/// keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.as_ref().hash(&mut hasher);
let key = hasher.finish();
AVATAR_HANDLE_CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_insert_with(|| iced::widget::image::Handle::from_bytes(bytes))
.clone()
.get_or_insert(bytes.as_ref(), || {
iced::widget::image::Handle::from_bytes(bytes.clone())
})
})
}