matrix-sdk-ui: Incomplete edit validation
Description
Impact
The message edit validation logic in the matrix-sdk-ui crate before 0.16.1 is missing a check: when replacing an encrypted event, the replacement event itself is not required to be encrypted. This enables a malicious homeserver administrator (or an actor with equivalent power) to impersonate or spoof messages as if they were sent by a victim user.
Patches
matrix-sdk-ui 0.16.1 fixes the message edit validation logic to align with the algorithm for replacement events[^1] described in the Matrix specification.
Workarounds
N/A
### References * Pull request: https://github.com/matrix-org/matrix-rust-sdk/pull/6454
For more information
If you have any questions or comments about this advisory, please email us at security at matrix.org.
[^1]: https://spec.matrix.org/unstable/client-server-api/#validity-of-replacement-events
Affected products
2- Range: <0.16.1
Patches
2f1bea7288bf3Put the correct security impact for CVE-2026-45057
2 files changed · +8 −1
crates/matrix-sdk/CHANGELOG.md+1 −1 modified@@ -46,7 +46,7 @@ All notable changes to this project will be documented in this file. ### Security fixes - Reject invalid edits as candidates for the latest event. - ([#6454](https://github.com/matrix-org/matrix-rust-sdk/pull/6454), High, + ([#6454](https://github.com/matrix-org/matrix-rust-sdk/pull/6454), Moderate, [CVE-2026-45057](https://www.cve.org/CVERecord?id=CVE-2026-45057), [GHSA-h97m-27fx-42rx](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-h97m-27fx-42rx))
crates/matrix-sdk-ui/CHANGELOG.md+7 −0 modified@@ -21,6 +21,13 @@ All notable changes to this project will be documented in this file. ## [0.17.0] - 2026-05-08 +### Security fixes + +- Reject invalid edits as candidates for timeline updates. + ([#6454](https://github.com/matrix-org/matrix-rust-sdk/pull/6454), Moderate, + [CVE-2026-45057](https://www.cve.org/CVERecord?id=CVE-2026-45057), + [GHSA-h97m-27fx-42rx](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-h97m-27fx-42rx)) + ### Bug fixes - Fix a possible panic in `RoomList::entries_with_dynamic_adapters`.
1e74c4835111fix(thread): Only apply valid edits in the thread summary
2 files changed · +130 −4
crates/matrix-sdk/src/event_cache/caches/room/state.rs+20 −3 modified@@ -24,6 +24,7 @@ use eyeball::SharedObservable; use eyeball_im::VectorDiff; use matrix_sdk_base::{ RoomInfoNotableUpdateReasons, StateChanges, apply_redaction, + check_validity_of_replacement_events, deserialized_responses::{ThreadSummary, ThreadSummaryStatus}, event_cache::{ Event, Gap, @@ -1122,12 +1123,28 @@ impl<'a> RoomEventCacheStateLockWriteGuard<'a> { // If there's an edit to the latest event in the thread, use the latest edit // event id as the latest event id for the thread summary. if let Some(event_id) = latest_event_id.as_ref() - && let Some((_, edits)) = self + && let Some((original_event, edits)) = self .find_event_with_relations(event_id, Some(vec![RelationType::Replacement])) .await? - && let Some(latest_edit) = edits.last() { - latest_event_id = latest_edit.event_id(); + let latest_valid_edit = edits.into_iter().rfind(|edit| { + let original_json = original_event.raw(); + let original_encryption_info = original_event.encryption_info(); + let replacement_json = edit.raw(); + let replacement_encryption_info = edit.encryption_info(); + + check_validity_of_replacement_events( + original_json, + original_encryption_info.map(|v| &**v), + replacement_json, + replacement_encryption_info.map(|v| &**v), + ) + .is_ok() + }); + + if let Some(latest_valid_edit) = latest_valid_edit { + latest_event_id = latest_valid_edit.event_id(); + } } self.maybe_update_thread_summary(thread_root, latest_event_id, post_processing_origin)
crates/matrix-sdk/tests/integration/event_cache/threads.rs+110 −1 modified@@ -12,11 +12,14 @@ use matrix_sdk::{ assert_event_matches_msg, mocks::{MatrixMockServer, RoomRelationsResponseTemplate}, }, + timeout::timeout, }; use matrix_sdk_test::{ALICE, JoinedRoomBuilder, async_test, event_factory::EventFactory}; use ruma::{ OwnedEventId, OwnedRoomId, event_id, - events::{AnySyncTimelineEvent, Mentions}, + events::{ + AnySyncTimelineEvent, Mentions, room::message::RoomMessageEventContentWithoutRelation, + }, push::{ConditionalPushRule, Ruleset}, room_id, serde::Raw, @@ -970,3 +973,109 @@ async fn test_redact_touches_threads() { } } } + +#[async_test] +async fn test_edits_touches_threads() { + // We start with a thread with some replies, then receive an edit and an invalid + // edit for the replies over sync. We observe that valid edits update the + // thread linked chunks as well as the thread summary on the thread root + // event. Invalid ones don't update the state. + + let s = thread_subscription_test_setup().await; + let f = s.factory; + + let event_cache = s.client.event_cache(); + event_cache.subscribe().unwrap(); + + let thread_root_id = s.thread_root; + + let room = s.server.sync_joined_room(&s.client, &s.room_id).await; + + let (room_event_cache, _drop_handles) = room.event_cache().await.unwrap(); + + let (thread_events, mut thread_stream) = + room_event_cache.subscribe_to_thread(thread_root_id.to_owned()).await.unwrap(); + + // Receive a thread root, and a threaded reply. + s.server + .sync_room( + &s.client, + JoinedRoomBuilder::new(&s.room_id) + .add_timeline_event(f.text_msg("Talking to myself.").event_id(&thread_root_id)) + .add_timeline_event(s.events[0].clone()) + .add_timeline_event(s.events[1].clone()), + ) + .await; + + wait_for_initial_events(thread_events, &mut thread_stream).await; + let (room_events, mut room_stream) = room_event_cache.subscribe().await.unwrap(); + + // A valid edit for the first reply comes through sync. + let valid_edit_event_id = event_id!("$valid_edit"); + s.server + .sync_room( + &s.client, + JoinedRoomBuilder::new(&s.room_id).add_timeline_event( + f.text_msg("Nobody speaks English anymore.").event_id(valid_edit_event_id).edit( + &room_events[2].event_id().unwrap(), + RoomMessageEventContentWithoutRelation::text_plain("edited text"), + ), + ), + ) + .await; + + // Edits are not emitted over the thread subscriber, the timeline uses the + // normal room stream to handle those. + // + // So we're going to look only at the room stream and see if the thread summary + // gets correctly updated. + { + // The room stream receives an update. + assert_let_timeout!( + Ok(RoomEventCacheUpdate::UpdateTimelineEvents(TimelineVectorDiffs { diffs, .. })) = + room_stream.recv() + ); + assert_eq!(diffs.len(), 2); + + // The edit gets appended to the stream. + assert_let!(VectorDiff::Append { values: new_events } = &diffs[0]); + assert_eq!(new_events.len(), 1); + assert_eq!(new_events[0].event_id().as_deref(), Some(valid_edit_event_id)); + + // The thread summary is updated. + { + assert_let!(VectorDiff::Set { index: 0, value: new_root } = &diffs[1]); + assert_eq!(new_root.event_id().as_ref(), Some(&thread_root_id)); + let summary = new_root.thread_summary.summary().unwrap(); + assert_eq!(summary.latest_reply.as_deref(), Some(valid_edit_event_id)); + assert_eq!(summary.num_replies, 2); + } + } + + // An invalid edit for the second reply comes through sync. + let invalid_edit_id = event_id!("$invalid_edit"); + s.server + .sync_room( + &s.client, + JoinedRoomBuilder::new(&s.room_id).add_timeline_event( + f.text_msg("Nobody speaks english anymore.").event_id(invalid_edit_id).edit( + &room_events[2].event_id().unwrap(), + RoomMessageEventContentWithoutRelation::text_plain("edited text"), + ), + ), + ) + .await; + + // It's a bit hard to know when the update should have been ready. This makes it + // hard to prove that no update happened. + let result = timeout(room_stream.recv(), Duration::from_secs(1)).await; + + assert!(result.is_err(), "The room stream should have timed out as the edit was invnalid"); + + let room_events = room_event_cache.events().await.unwrap(); + let first = room_events.first().unwrap(); + let thread_summary = first.thread_summary.summary().unwrap(); + + // The latest reply should still be our valid event, not our invalid one. + assert_eq!(thread_summary.latest_reply.as_deref(), Some(valid_edit_event_id)); +}
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
5- github.com/advisories/GHSA-h97m-27fx-42rxghsaADVISORY
- github.com/matrix-org/matrix-rust-sdk/pull/6454ghsa
- github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.16.1ghsa
- github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-h97m-27fx-42rxghsa
- rustsec.org/advisories/RUSTSEC-2026-0158.htmlghsa
News mentions
0No linked articles in our index yet.