CVE-2022-23656: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Zulip is an open source team chat app. The `main` development branch of Zulip Server from June 2021 and later is vulnerable to a cross-site scripting vulnerability on the recent topics page. An attacker could maliciously craft a full name for their account and send messages to a topic with several participants; a victim who then opens an overflow tooltip including this full name on the recent topics page could trigger execution of JavaScript code controlled by the attacker. Users running a Zulip server from the main branch should upgrade from main (2022-03-01 or later) again to deploy this fix.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2022-03-02T21:15Z
Repository: https://github.com/zulip/zulip
Language: JavaScript
Files changed: static/js/popover_menus.js, static/js/recent_topics_ui.js, static/js/rendered_markdown.js, static/js/timerender.ts, static/js/tippyjs.js, static/js/ui_init.js, static/templates/compose.hbs, static/templates/compose_control_buttons.hbs, static/templates/recent_topic_row.hbs, static/templates/right_sidebar.hbs
Commit date: 2022-03-01 14:26:42 -0800
Commit message: CVE-2022-23656: Fix cross-site scripting vulnerability in tooltips.

An attacker could maliciously craft a full name for their account and
send messages to a topic with several participants; a victim who then
opens an overflow tooltip including this full name on the recent
topics page could trigger execution of JavaScript code controlled by
the attacker.

Signed-off-by: Anders Kaseorg <anders@zulip.com>

Diff: diff --git a/static/js/popover_menus.js b/static/js/popover_menus.js
index 77713b68783..5037e365d68 100644
--- a/static/js/popover_menus.js
+++ b/static/js/popover_menus.js
@@ -30,11 +30,10 @@ export function get_compose_control_buttons_popover() {
 
 const default_popover_props = {
     delay: 0,
     appendTo: () => document.body,
     trigger: "click",
-    allowHTML: true,
     interactive: true,
     hideOnClick: true,
     /* The light-border TippyJS theme is a bit of a misnomer; it
        is a popover styling similar to Bootstrap.  We've also customized
        its CSS to support Zulip's dark theme. */
@@ -58,10 +57,11 @@ function on_show_prep(instance) {
 
 export function initialize() {
     delegate("body", {
         ...default_popover_props,
         target: "#streams_inline_icon",
+        allowHTML: true,
         onShow(instance) {
             const can_create_streams =
                 settings_data.user_can_create_private_streams() ||
                 settings_data.user_can_create_public_streams() ||
                 settings_data.user_can_create_web_public_streams();
@@ -87,10 +87,11 @@ export function initialize() {
     // compose box buttons popover shown on mobile widths.
     delegate("body", {
         ...default_popover_props,
         target: ".compose_mobile_button",
         placement: "top",
+        allowHTML: true,
         onShow(instance) {
             on_show_prep(instance);
             instance.setContent(
                 render_mobile_message_buttons_popover_content({
                     is_in_private_narrow: narrow_state.narrowed_to_pms(),
@@ -123,10 +124,11 @@ export function initialize() {
     // So, probably a bug on our side.
     delegate("body", {
         ...default_popover_props,
         target: ".compose_control_menu_wrapper",
         placement: "top",
+        allowHTML: true,
         onShow(instance) {
             instance.setContent(
                 render_compose_control_buttons_popover({
                     giphy_enabled: giphy.is_giphy_enabled(),
                 }),
@@ -141,10 +143,11 @@ export function initialize() {
 
     delegate("body", {
         ...default_popover_props,
         target: ".enter_sends",
         placement: "top",
+        allowHTML: true,
         onShow(instance) {
             on_show_prep(instance);
             instance.setContent(
                 render_compose_select_enter_behaviour_popover({
                     enter_sends_true: user_settings.enter_sends,
diff --git a/static/js/recent_topics_ui.js b/static/js/recent_topics_ui.js
index f86cdb29c12..c8b8406d557 100644
--- a/static/js/recent_topics_ui.js
+++ b/static/js/recent_topics_ui.js
@@ -1,6 +1,7 @@
 import $ from "jquery";
+import _ from "lodash";
 
 import render_recent_topic_row from "../templates/recent_topic_row.hbs";
 import render_recent_topics_filters from "../templates/recent_topics_filters.hbs";
 import render_recent_topics_body from "../templates/recent_topics_table.hbs";
 
@@ -289,11 +290,13 @@ function format_topic(topic_data) {
                 },
                 {remaining_senders},
             ),
         );
     }
-    const other_sender_names = displayed_other_names.join("<br/>");
+    const other_sender_names_html = displayed_other_names
+        .map((name) => _.escape(name))
+        .join("<br />");
 
     return {
         // stream info
         stream_id,
         stream,
@@ -307,11 +310,11 @@ function format_topic(topic_data) {
         unread_count,
         last_msg_time,
         topic_url: hash_util.by_stream_topic_uri(stream_id, topic),
         senders: senders_info,
         other_senders_count: Math.max(0, all_senders.length - MAX_AVATAR),
-        other_sender_names,
+        other_sender_names_html,
         muted,
         topic_muted,
         participated: topic_data.participated,
         full_last_msg_date_time: full_datetime,
     };
diff --git a/static/js/rendered_markdown.js b/static/js/rendered_markdown.js
index 77e1f41447c..66774fc9b87 100644
--- a/static/js/rendered_markdown.js
+++ b/static/js/rendered_markdown.js
@@ -169,11 +169,13 @@ export const update_elements = (content) => {
             const rendered_time = timerender.render_markdown_timestamp(timestamp, text);
             const rendered_timestamp = render_markdown_timestamp({
                 text: rendered_time.text,
             });
             $(this).html(rendered_timestamp);
-            $(this).attr("data-tippy-content", rendered_time.tooltip_content);
+            $(this)
+                .attr("data-tippy-content", rendered_time.tooltip_content_html)
+                .attr("data-tippy-allowHTML", "true");
         } else {
             // This shouldn't happen. If it does, we're very interested in debugging it.
             blueslip.error(`Could not parse datetime supplied by backend: ${time_str}`);
         }
     });
diff --git a/static/js/timerender.ts b/static/js/timerender.ts
index 688ad26a23b..7ddf48e7a32 100644
--- a/static/js/timerender.ts
+++ b/static/js/timerender.ts
@@ -224,21 +224,21 @@ export function render_date(time: Date, time_above: Date | undefined, today: Dat
 }
 
 // Renders the timestamp returned by the <time:> Markdown syntax.
 export function render_markdown_timestamp(time: number | Date): {
     text: string;
-    tooltip_content: string;
+    tooltip_content_html: string;
 } {
     const hourformat = user_settings.twenty_four_hour_time ? "HH:mm" : "h:mm a";
     const timestring = format(time, "E, MMM d yyyy, " + hourformat);
 
     const tz_offset_str = get_tz_with_UTC_offset(time);
-    const tooltip_html_content = render_markdown_time_tooltip({tz_offset_str});
+    const tooltip_content_html = render_markdown_time_tooltip({tz_offset_str});
 
     return {
         text: timestring,
-        tooltip_content: tooltip_html_content,
+        tooltip_content_html: tooltip_content_html,
     };
 }
 
 // This isn't expected to be called externally except manually for
 // testing purposes.
diff --git a/static/js/tippyjs.js b/static/js/tippyjs.js
index 00705d278ef..065bb3e46e8 100644
--- a/static/js/tippyjs.js
+++ b/static/js/tippyjs.js
@@ -33,11 +33,11 @@ tippy.setDefaultProps({
     // This has the side effect of some properties of parent applying to
     // tooltips.
     appendTo: "parent",
 
     // html content is not supported by default
-    // enable it by passing data-tippy-allowHtml="true"
+    // enable it by passing data-tippy-allowHTML="true"
     // in the tag or a parameter.
 });
 
 export function initialize() {
     // Our default tooltip configuration. For this, one simply needs to:
@@ -198,11 +198,10 @@ export function initialize() {
         target: [
             ".rendered_markdown time",
             ".rendered_markdown .copy_codeblock",
             "#compose_top_right [data-tippy-content]",
         ],
-        allowHTML: true,
         appendTo: () => document.body,
         onHidden(instance) {
             instance.destroy();
         },
     });
diff --git a/static/js/ui_init.js b/static/js/ui_init.js
index eef0db74927..4157ea61c68 100644
--- a/static/js/ui_init.js
+++ b/static/js/ui_init.js
@@ -201,11 +201,11 @@ function initialize_compose_box() {
     $("#compose-container").append(
         render_compose({
             embedded: $("#compose").attr("data-embedded") === "",
             file_upload_enabled: page_params.max_file_upload_size_mib > 0,
             giphy_enabled: giphy.is_giphy_enabled(),
-            scroll_to_bottom_key: common.has_mac_keyboard()
+            scroll_to_bottom_key_html: common.has_mac_keyboard()
                 ? "Fn + <span class='tooltip_right_arrow'>→</span>"
                 : "End",
         }),
     );
     $(`.enter_sends_${user_settings.enter_sends}`).show();
diff --git a/static/templates/compose.hbs b/static/templates/compose.hbs
index c2ca696dc4b..86058345ede 100644
--- a/static/templates/compose.hbs
+++ b/static/templates/compose.hbs
@@ -2,11 +2,11 @@
     {{!-- scroll to bottom button is not part of compose but
     helps us align it at various screens sizes with
     minimal css and no JS. We keep it `position: absolute` to prevent
     it changing compose box layout in any way. --}}
     <div id="scroll-to-bottom-button-container">
-        <div id="scroll-to-bottom-button-clickable-area"  data-tippy-content="{{t 'Scroll to bottom' }}  <span class='hotkey-hint'>({{scroll_to_bottom_key}})</span>" data-tippy-allowHtml="true">
+        <div id="scroll-to-bottom-button-clickable-area"  data-tippy-content="{{t 'Scroll to bottom' }}  &lt;span class='hotkey-hint'&gt;({{scroll_to_bottom_key_html}})&lt;/span&gt;" data-tippy-allowHTML="true">
             <div id="scroll-to-bottom-button">
                 <i class="fa fa-chevron-down"></i>
             </div>
         </div>
     </div>
diff --git a/static/templates/compose_control_buttons.hbs b/static/templates/compose_control_buttons.hbs
index eac9239fbd8..cca02a76cca 100644
--- a/static/templates/compose_control_buttons.hbs
+++ b/static/templates/compose_control_buttons.hbs
@@ -5,11 +5,11 @@
     {{/if}}
     <a role="button" class="markdown_preview compose_control_button fa fa-eye" aria-label="{{t 'Preview' }}" tabindex=0 data-tippy-content="{{t 'Preview' }}"></a>
     <a role="button" class="undo_markdown_preview compose_control_button fa fa-edit" aria-label="{{t 'Write' }}" tabindex=0 style="display:none;" data-tippy-content="{{t 'Write' }}"></a>
     <a role="button" class="compose_control_button fa fa-video-camera video_link" aria-label="{{t 'Add video call' }}" tabindex=0 data-tippy-content="{{t 'Add video call' }}"></a>
     <a role="button" class="compose_control_button fa fa-smile-o emoji_map" aria-label="{{t 'Add emoji' }}" tabindex=0 data-tippy-content="{{t 'Add emoji' }}"></a>
-    <a role="button" class="compose_control_button fa fa-clock-o time_pick" aria-label="{{t 'Add global time' }}" tabindex=0 data-tippy-content="{{t 'Add global time<br />Everyone sees global times in their own time zone.' }}" data-tippy-maxWidth="none" data-tippy-allowHtml="true"></a>
+    <a role="button" class="compose_control_button fa fa-clock-o time_pick" aria-label="{{t 'Add global time' }}" tabindex=0 data-tippy-content="{{t 'Add global time<br />Everyone sees global times in their own time zone.' }}" data-tippy-allowHTML="true" data-tippy-maxWidth="none"></a>
     <a role="button" class="compose_control_button compose_gif_icon {{#unless giphy_enabled }} hide {{/unless}} zulip-icon zulip-icon-gif" aria-label="{{t 'Add GIF' }}" tabindex=0 data-tippy-content="{{t 'Add GIF' }}"></a>
     <div class="divider hide-sm">|</div>
     <div class="{{#if message_id}}hide-lg{{else}}hide-sm{{/if}}">
         {{> compose_control_buttons_in_popover}}
     </div>
diff --git a/static/templates/recent_topic_row.hbs b/static/templates/recent_topic_row.hbs
index b2d2b1acf4d..fe80d8d808b 100644
--- a/static/templates/recent_topic_row.hbs
+++ b/static/templates/recent_topic_row.hbs
@@ -30,11 +30,11 @@
         </div>
     </td>
     <td class='recent_topic_users'>
         <ul class="recent_topics_participants">
             {{#if other_senders_count}}
-            <li class="recent_topics_participant_item tippy-zulip-tooltip" data-tippy-content="{{other_sender_names}}" data-tippy-allowHtml="true">
+            <li class="recent_topics_participant_item tippy-zulip-tooltip" data-tippy-content="{{other_sender_names_html}}" data-tippy-allowHTML="true">
                 <span class="recent_topics_participant_overflow">+{{other_senders_count}}</span>
             </li>
             {{/if}}
             {{#each senders}}
                 {{#if this.is_muted}}
diff --git a/static/templates/right_sidebar.hbs b/static/templates/right_sidebar.hbs
index f84458b67ec..742039e3ab7 100644
--- a/static/templates/right_sidebar.hbs
+++ b/static/templates/right_sidebar.hbs
@@ -25,11 +25,11 @@
         <div class="right-sidebar-shortcuts">
             {{#if can_invite_others_to_realm}}
             <a id="invite-user-link" href="#invite"><i class="fa fa-user-plus" aria-hidden="true"></i>{{t 'Invite more users' }}</a>
             {{/if}}
             <a id="sidebar-keyboard-shortcuts" data-overlay-trigger="keyboard-shortcuts" class="hidden-for-spectators">
-                <i class="fa fa-keyboard-o fa-2x tippy-zulip-tooltip" id="keyboard-icon" data-tippy-allowHtml="true" data-tippy-content="{{t 'Keyboard shortcuts' }} <span class='hotkey-hint'>(?)</span>"></i>
+                <i class="fa fa-keyboard-o fa-2x tippy-zulip-tooltip" id="keyboard-icon" data-tippy-allowHTML="true" data-tippy-content="{{t 'Keyboard shortcuts' }} &lt;span class='hotkey-hint'&gt;(?)&lt;/span&gt;"></i>
             </a>
             <div class="only-visible-for-spectators">
                 <div class="realm-description">
                     <div class="rendered_markdown">{{rendered_markdown realm_description }}</div>
                     <hr />

Vulnerable code:     allowHTML: true,
    const other_sender_names = displayed_other_names.join("<br/>");
        other_sender_names,
            $(this).attr("data-tippy-content", rendered_time.tooltip_content);
    tooltip_content: string;
    const tooltip_html_content = render_markdown_time_tooltip({tz_offset_str});
        tooltip_content: tooltip_html_content,
    // enable it by passing data-tippy-allowHtml="true"
        allowHTML: true,
            scroll_to_bottom_key: common.has_mac_keyboard()
        <div id="scroll-to-bottom-button-clickable-area"  data-tippy-content="{{t 'Scroll to bottom' }}  <span class='hotkey-hint'>({{scroll_to_bottom_key}})</span>" data-tippy-allowHtml="true">
    <a role="button" class="compose_control_button fa fa-clock-o time_pick" aria-label="{{t 'Add global time' }}" tabindex=0 data-tippy-content="{{t 'Add global time<br />Everyone sees global times in their own time zone.' }}" data-tippy-maxWidth="none" data-tippy-allowHtml="true"></a>
            <li class="recent_topics_participant_item tippy-zulip-tooltip" data-tippy-content="{{other_sender_names}}" data-tippy-allowHtml="true">
                <i class="fa fa-keyboard-o fa-2x tippy-zulip-tooltip" id="keyboard-icon" data-tippy-allowHtml="true" data-tippy-content="{{t 'Keyboard shortcuts' }} <span class='hotkey-hint'>(?)</span>"></i>
Fixed code:         allowHTML: true,
        allowHTML: true,
        allowHTML: true,
        allowHTML: true,
import _ from "lodash";
    const other_sender_names_html = displayed_other_names
        .map((name) => _.escape(name))
        .join("<br />");
        other_sender_names_html,
            $(this)
                .attr("data-tippy-content", rendered_time.tooltip_content_html)
                .attr("data-tippy-allowHTML", "true");
    tooltip_content_html: string;
    const tooltip_content_html = render_markdown_time_tooltip({tz_offset_str});
        tooltip_content_html: tooltip_content_html,
    // enable it by passing data-tippy-allowHTML="true"
            scroll_to_bottom_key_html: common.has_mac_keyboard()
        <div id="scroll-to-bottom-button-clickable-area"  data-tippy-content="{{t 'Scroll to bottom' }}  &lt;span class='hotkey-hint'&gt;({{scroll_to_bottom_key_html}})&lt;/span&gt;" data-tippy-allowHTML="true">
    <a role="button" class="compose_control_button fa fa-clock-o time_pick" aria-label="{{t 'Add global time' }}" tabindex=0 data-tippy-content="{{t 'Add global time<br />Everyone sees global times in their own time zone.' }}" data-tippy-allowHTML="true" data-tippy-maxWidth="none"></a>
            <li class="recent_topics_participant_item tippy-zulip-tooltip" data-tippy-content="{{other_sender_names_html}}" data-tippy-allowHTML="true">
                <i class="fa fa-keyboard-o fa-2x tippy-zulip-tooltip" id="keyboard-icon" data-tippy-allowHTML="true" data-tippy-content="{{t 'Keyboard shortcuts' }} &lt;span class='hotkey-hint'&gt;(?)&lt;/span&gt;"></i>