From 189050f3f49e6e692ddb22895509109f618c94f2 Mon Sep 17 00:00:00 2001 From: Michael Vetter Date: Thu, 19 Mar 2026 09:40:44 +0100 Subject: [PATCH] feat: Sanitize illegal XML characters from outgoing messages Filter out control characters (U+0000 to U+001F) from outgoing messages, as they are illegal in XML 1.0 (except for \t, \n, and \r). This prevents XMPP servers from closing the connection when such characters are accidentally or intentionally included in a message. Fixes: https://github.com/profanity-im/profanity/issues/1437 --- src/common.c | 27 +++++++++++++++++++++++++++ src/common.h | 1 + src/event/client_events.c | 9 ++++++--- tests/unittests/test_common.c | 32 ++++++++++++++++++++++++++++++++ tests/unittests/test_common.h | 1 + tests/unittests/unittests.c | 1 + 6 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/common.c b/src/common.c index 44c2fcff..eb1d1e8a 100644 --- a/src/common.c +++ b/src/common.c @@ -418,6 +418,33 @@ utf8_display_len(const char* const str) return len; } +/** + * Removes illegal XML 1.0 characters from a string. + * + * This function creates a new string that excludes characters in the range + * U+0000 to U+001F, except for U+0009 (TAB), U+000A (LF), and U+000D (CR). + */ +gchar* +str_xml_sanitize(const char* const str) +{ + if (str == NULL) { + return NULL; + } + + GString* sanitized = g_string_new_len(NULL, strlen(str)); + const char* curr = str; + + while (*curr != '\0') { + gunichar c = g_utf8_get_char(curr); + if ((c >= 0x20) || (c == 0x09) || (c == 0x0A) || (c == 0x0D)) { + g_string_append_unichar(sanitized, c); + } + curr = g_utf8_next_char(curr); + } + + return g_string_free(sanitized, FALSE); +} + char* release_get_latest(void) { diff --git a/src/common.h b/src/common.h index 0dccf9b5..cb3155e4 100644 --- a/src/common.h +++ b/src/common.h @@ -159,6 +159,7 @@ gboolean copy_file(const char* const src, const char* const target, const gboole char* str_replace(const char* string, const char* substr, const char* replacement); gboolean strtoi_range(const char* str, int* saveptr, int min, int max, char** err_msg); int utf8_display_len(const char* const str); +gchar* str_xml_sanitize(const char* const str); gboolean string_matches_one_of(const char* what, const char* is, gboolean is_can_be_null, const char* first, ...) __attribute__((sentinel)); gboolean valid_tls_policy_option(const char* is); diff --git a/src/event/client_events.c b/src/event/client_events.c index 51883197..498863a6 100644 --- a/src/event/client_events.c +++ b/src/event/client_events.c @@ -115,7 +115,8 @@ cl_ev_send_msg_correct(ProfChatWin* chatwin, const char* const msg, const char* } auto_char char* plugin_msg = plugins_pre_chat_message_send(chatwin->barejid, msg); - const char* const message = plugin_msg ?: msg; + auto_gchar gchar* sanitized_msg = str_xml_sanitize(plugin_msg ?: msg); + const char* message = sanitized_msg; char* replace_id = NULL; if (correct_last_msg) { @@ -177,7 +178,8 @@ void cl_ev_send_muc_msg_corrected(ProfMucWin* mucwin, const char* const msg, const char* const oob_url, gboolean correct_last_msg) { auto_char char* plugin_msg = plugins_pre_room_message_send(mucwin->roomjid, msg); - const char* const message = plugin_msg ?: msg; + auto_gchar gchar* sanitized_msg = str_xml_sanitize(plugin_msg ?: msg); + const char* message = sanitized_msg; char* replace_id = NULL; if (correct_last_msg) { @@ -218,7 +220,8 @@ cl_ev_send_priv_msg(ProfPrivateWin* privwin, const char* const msg, const char* privwin_message_left_room(privwin); } else { auto_char char* plugin_msg = plugins_pre_priv_message_send(privwin->fulljid, msg); - const char* const message = plugin_msg ?: msg; + auto_gchar gchar* sanitized_msg = str_xml_sanitize(plugin_msg ?: msg); + const char* message = sanitized_msg; auto_jid Jid* jidp = jid_create(privwin->fulljid); auto_char char* id = message_send_private(privwin->fulljid, message, oob_url); diff --git a/tests/unittests/test_common.c b/tests/unittests/test_common.c index cd62c84d..6a7bbb9f 100644 --- a/tests/unittests/test_common.c +++ b/tests/unittests/test_common.c @@ -1281,3 +1281,35 @@ string_matches_one_of__tests__edge_cases(void** state) // is is an empty string, one of the options is an empty string assert_true(string_matches_one_of("Test", "", FALSE, "option1", "", "option2", NULL)); } + +void +str_xml_sanitize__strips_illegal_characters(void** state) +{ + // Test NULL input + assert_null(str_xml_sanitize(NULL)); + + // Test empty string + gchar* res1 = str_xml_sanitize(""); + assert_string_equal("", res1); + g_free(res1); + + // Test string with no illegal characters + gchar* res2 = str_xml_sanitize("Hello World! \t\n\r"); + assert_string_equal("Hello World! \t\n\r", res2); + g_free(res2); + + // Test string with illegal characters (0x16 is ^V, 0x01 is ^A) + gchar* res3 = str_xml_sanitize("Hello\x16World\x01!"); + assert_string_equal("HelloWorld!", res3); + g_free(res3); + + // Test string with mixed legal and illegal control characters + gchar* res4 = str_xml_sanitize("\x09Legal\x0BIllegal\x0ALegal\x1FIllegal\x0DLegal"); + assert_string_equal("\tLegalIllegal\nLegalIllegal\rLegal", res4); + g_free(res4); + + // Test UTF-8 characters + gchar* res5 = str_xml_sanitize("UTF-8: üñîçøðé \x16 and more"); + assert_string_equal("UTF-8: üñîçøðé and more", res5); + g_free(res5); +} diff --git a/tests/unittests/test_common.h b/tests/unittests/test_common.h index 50ce1262..c3e97bf8 100644 --- a/tests/unittests/test_common.h +++ b/tests/unittests/test_common.h @@ -60,5 +60,6 @@ void string_matches_one_of__tests__edge_cases(void** state); void valid_tls_policy_option__is__correct_for_various_inputs(void** state); void get_mentions__tests__various(void** state); void release_is_new__tests__various(void** state); +void str_xml_sanitize__strips_illegal_characters(void** state); #endif diff --git a/tests/unittests/unittests.c b/tests/unittests/unittests.c index 1dede0e5..d3f76ca2 100644 --- a/tests/unittests/unittests.c +++ b/tests/unittests/unittests.c @@ -661,6 +661,7 @@ main(int argc, char* argv[]) cmocka_unit_test(prof_occurrences__tests__large_message), cmocka_unit_test(get_mentions__tests__various), cmocka_unit_test(release_is_new__tests__various), + cmocka_unit_test(str_xml_sanitize__strips_illegal_characters), cmocka_unit_test_setup_teardown(plugins_get_command_names__returns__no_commands, load_preferences,