diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index ef6df0eb..de8fcbb8 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -1,5 +1,10 @@ # [unreleased] +Bug fixes: + +* HTML-relevant characters (`<`, `>`, etc) in plaintext replies are now escaped + during creation of the rich reply + Breaking changes: * Remove deprecated `EventType` enum diff --git a/crates/ruma-common/src/events/room/message/reply.rs b/crates/ruma-common/src/events/room/message/reply.rs index 2ae771db..e9d6c2ab 100644 --- a/crates/ruma-common/src/events/room/message/reply.rs +++ b/crates/ruma-common/src/events/room/message/reply.rs @@ -48,6 +48,29 @@ fn get_message_quote_fallbacks(original_message: &OriginalRoomMessageEvent) -> ( } } +/// Converts a plaintext body to HTML, escaping any characters that would cause problems. +fn escape_html_entities(body: &str) -> String { + let mut escaped_body = String::with_capacity(body.len()); + for c in body.chars() { + // Escape reserved HTML entities and new lines. + // + let s = match c { + '&' => Some("&"), + '<' => Some("<"), + '>' => Some(">"), + '"' => Some("""), + '\n' => Some("
"), + _ => None, + }; + if let Some(s) = s { + escaped_body.push_str(s); + } else { + escaped_body.push(c); + } + } + escaped_body +} + fn formatted_or_plain_body( formatted: Option<&FormattedBody>, body: &str, @@ -64,25 +87,7 @@ fn formatted_or_plain_body( #[cfg(not(feature = "unstable-sanitize"))] formatted_body.body.clone() } else { - let mut escaped_body = String::with_capacity(body.len()); - for c in body.chars() { - // Escape reserved HTML entities and new lines. - // - let s = match c { - '&' => Some("&"), - '<' => Some("<"), - '>' => Some(">"), - '"' => Some("""), - '\n' => Some("
"), - _ => None, - }; - if let Some(s) = s { - escaped_body.push_str(s); - } else { - escaped_body.push(c); - } - } - escaped_body + escape_html_entities(body) } } @@ -97,7 +102,7 @@ fn formatted_or_plain_body( /// [HTML tags and attributes]: https://spec.matrix.org/v1.4/client-server-api/#mroommessage-msgtypes /// [rich reply fallbacks]: https://spec.matrix.org/v1.4/client-server-api/#fallbacks-for-rich-replies pub fn plain_and_formatted_reply_body( - body: impl fmt::Display, + body: &str, formatted: Option, original_message: &OriginalRoomMessageEvent, ) -> (String, String) { @@ -106,7 +111,7 @@ pub fn plain_and_formatted_reply_body( let plain = format!("{quoted}\n{body}"); let html = match formatted { Some(formatted) => format!("{quoted_html}{formatted}"), - None => format!("{quoted_html}{body}"), + None => format!("{quoted_html}{}", escape_html_entities(body)), }; (plain, html) diff --git a/crates/ruma-common/tests/events/room_message.rs b/crates/ruma-common/tests/events/room_message.rs index 48c8f1a2..4d26001a 100644 --- a/crates/ruma-common/tests/events/room_message.rs +++ b/crates/ruma-common/tests/events/room_message.rs @@ -8,8 +8,9 @@ use ruma_common::{ key::verification::VerificationMethod, room::{ message::{ - AudioMessageEventContent, KeyVerificationRequestEventContent, MessageType, - OriginalRoomMessageEvent, RoomMessageEventContent, TextMessageEventContent, + AudioMessageEventContent, ForwardThread, KeyVerificationRequestEventContent, + MessageType, OriginalRoomMessageEvent, RoomMessageEventContent, + TextMessageEventContent, }, MediaSource, }, @@ -434,6 +435,53 @@ fn content_deserialization_failure() { assert_matches!(from_json_value::(json_data), Err(_)); } +#[test] +fn escape_tags_in_plain_reply_body() { + let first_message = OriginalRoomMessageEvent { + content: RoomMessageEventContent::text_plain("Usage: cp "), + event_id: event_id!("$143273582443PhrSn:example.org").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)), + room_id: room_id!("!testroomid:example.org").to_owned(), + sender: user_id!("@user:example.org").to_owned(), + unsigned: MessageLikeUnsigned::default(), + }; + let second_message = RoomMessageEventContent::text_plain("Usage: rm ") + .make_reply_to(&first_message, ForwardThread::Yes); + + let body = assert_matches!( + first_message.content.msgtype, + MessageType::Text(TextMessageEventContent { body, formatted: None, .. }) => body + ); + assert_eq!(body, "Usage: cp "); + + let (body, formatted) = assert_matches!( + second_message.msgtype, + MessageType::Text(TextMessageEventContent { body, formatted, .. }) => (body, formatted) + ); + assert_eq!( + body, + "\ + > <@user:example.org> Usage: cp \n\ + Usage: rm \ + " + ); + let formatted = formatted.unwrap(); + assert_eq!( + formatted.body, + "\ + \ +
\ + In reply to \ + @user:example.org\ +
\ + Usage: cp <source> <destination>\ +
\ +
\ + Usage: rm <path>\ + " + ); +} + #[test] #[cfg(feature = "unstable-sanitize")] fn reply_sanitize() {