CVE-2022-21649 (JavaScript, published 2022-01-04T21:15Z)
Description: value: Convos is an open source multi-user chat that runs in a web browser. Characters starting with "https://" in the chat window create an <a> tag. Stored XSS vulnerability using onfocus and autofocus occurs because escaping exists for "<" or ">" but escaping for double quotes does not exist. Through this vulnerability, an attacker is capable to execute malicious scripts. Users are advised to update as soon as possible.
Severity: LOW (CVSS v3 base score: 5.4)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
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.
Repository: https://github.com/convos-chat/convos
Files changed:
Changes
__tests__/store/I18N.js
assets/store/I18N.js
Commit message: Fix XSS vulnerability in links and changed I18N.md() to be understand recursive tags #281 #600 #601


Vulnerable code:
 - Replaced CONVOS_DEBUG=1 with CONVOS_LOG_LEVEL=trace
    .toBe('<a href="https://commons.wikimedia.org/wiki/File:HK_WCD_WC_%E7%81%A3%E4%BB%94_Wan_Chai_%E8%BB%92%E5%B0%BC%E8%A9%A9%E9%81%93_Hennessy_Road_tram_body_ads_Tsingtao_Brewery_August_2021_SS2.jpg" target="_blank">https://commons.wikimedia.org/wiki/File:HK_WCD_WC_%E7%81%A3%E4%BB%94_Wan_Chai_%E8%BB%92%E5%B0%BC%E8%A9%A9%E9%81%93_Hennessy_Road_tram_body_ads_Tsingtao_Brewery_August_2021_SS2.jpg</a>');
test('md - not lmd', () => {
  expect(i18n.md('`code` %1 *is* cool.')).toBe('<code>code</code> %1 <em>is</em> cool.');
  expect(i18n.md('> Some quote')).toBe('<blockquote>Some quote</blockquote>');
test('md - em, strong', () => {
  expect(i18n.md('Hey *foo* "**bar**" ***baz***!'))
    .toBe('Hey <em>foo</em> "<strong>bar</strong>" <em><strong>baz</strong></em>!');
});

test('md - markdown link', () => {
  expect(i18n.md('some [cool chat](https://convos.chat)'))
    .toBe('some <a href="https://convos.chat" target="_blank">cool chat</a>');
    .toBe('A link to <a href="https://convos.chat" target="_blank">https://convos.chat</a>, cool ey?');
    .toBe('<a href=\"https://ru.wikipedia.org/wiki/Участница:Gryllida/Черновик\" target=\"_blank\">https://ru.wikipedia.org/wiki/Участница:Gryllida/Черновик</a> last symbol shows as separate outside of the URL? do you reproduce the bug?');
    .toBe('[mojo] marcusramberg opened pull request #1894: Minor tweaks to Growing guide. - <a href=\"https://git.io/JD9ph\" target=\"_blank\">https://git.io/JD9ph</a>');
});
test('md - code', () => {
  expect(i18n.md('single `a` char'))
    .toBe('single <code>a</code> char');
  expect(i18n.md('is this \\`not code`, or..?'))
    .toBe('is this `not code`, or..?');
  expect(i18n.md('is this `not code`, or..?'))
    .toBe('is this <code>not code</code>, or..?');
  expect(i18n.md('not a `https://link.com`'))
    .toBe('not a <code>https://link.com</code>');
  expect(i18n.md('a regexp: `TShop\.Setup\(\s*([{](?>[^\\"{}]+|"(?>[^\\"]+|\\[\S\s])*"|\\[\S\s]|(?-1))*[}])`'))
    .toBe('a regexp: <code>TShop\.Setup\(\s*([{](?&gt;[^\\"{}]+|"(?&gt;[^\\"]+|\\[\S\s])*"|\\[\S\s]|(?-1))*[}])</code>');
  expect(i18n.md('kikuchi` changed nick to kikuchi```.'))
    .toBe('kikuchi` changed nick to kikuchi```.');
test('md - nbsp', () => {
  expect(i18n.md('')).toBe('&nbsp;');
  expect(i18n.md('    ___ ___  _  ___   _____  ___')).toBe('&nbsp; &nbsp; ___ ___ &nbsp;_ &nbsp;___ &nbsp; _____ &nbsp;___');
  expect(i18n.md('want to join #foo-1.2 #foo-bar#not href="#anchor" #foo.bar'))
    .toBe('want to join <a href=\"./%23foo-1.2\">#foo-1.2</a> <a href=\"./%23foo-bar\">#foo-bar</a>#not href="#anchor" <a href=\"./%23foo.bar\">#foo.bar</a>');
import XRegExp from 'xregexp';
import {route} from '../store/Route';
const RE = {};
const STOP = ' ,.:;!"\'';
const XML_ESCAPE = {'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&apos;', '"': '&quot;'};
    this._state = {};
    str = this._xmlEscape(str);
    str = this._nbsp(str);
    if (!opt.raw) str = this._mdLink(str);
    if (!opt.raw) str = this._plainUrlToLink(str);
    if (!opt.raw) str = this._extendedFormatting(str);
    if (!opt.raw) str = this._mdCode(str);
    if (!opt.raw) str = this._mdEmStrong(str);
    if (!opt.raw) str = this.emojis.markup(str);
    if (!opt.raw) str = this._mdBlockQuote(str);
    if (!opt.raw) str = this._mdChannelsAndNicks(str);
    return str;
  // https://modern.ircdocs.horse/formatting.html
  _extendedFormatting(str) {
    const zeroTo99 = '0[0-9]|[1-9][0-9]';
    const colorRe = new RegExp('\x03(' + zeroTo99 + ')(?:,(' + zeroTo99 + '))?([^\x03]*)', 'g');
    return str.replace(colorRe, (all, fg, bg, text) => text).replace(/[\x02\x03\x1d\x1f\x1e\x11\x16\x0f]/g, '');
  }
  _mdBlockQuote(str) {
    return str.replace(/^&gt;\s(.*)/, (all, quote) => '<blockquote>' + quote + '</blockquote>');
  _mdChannelsAndNicks(str) {
    // TODO: Make nicks clickable
    return str.replace(/(^|\s)(#[a-zA-Z][\w.-]+)/g, (all, pre, channel) => {
      const suffix = channel.match(/\.$/) ? '.' : '';
      if (suffix) channel = channel.replace(/\.$/, '');
      return pre + '<a href="./' + route.urlFor(encodeURIComponent(channel)) + '">' + channel + '</a>' + suffix;
    });
  _mdCode(str) {
    return str.replace(/(\\?)`([^` ][^`]*)`/g, (all, esc, text) => {
      return esc ? all.replace(/^\\/, '') : '<code>' + text + '</code>';
    });
  _mdEmStrong(str) {
    return str.replace(/(^|\s|")(\\?)(\*+)(\w[^<]*?)\3/g, (all, b, esc, md, text) => {
      if (md.length == 1) return esc ? all.replace(/^\\/, '') : b + '<em>' + text + '</em>';
      if (md.length == 2) return esc ? all.replace(/^\\/, '') : b + '<strong>' + text + '</strong>';
      if (md.length == 3) return esc ? all.replace(/^\\/, '') : b + '<em><strong>' + text + '</strong></em>';
      return all;
    });
  _mdLink(str) {
    const re = RE.mdLink || (RE.mdLink = XRegExp('\\[ ([a-zA-Z][^\\]]+) \\] \\( ([^)]+) \\)', 'gx'));
    return XRegExp.replace(str, re, (all, text, href) => {
      const scheme = href.match(/^\s*(\w+):/) || ['', ''];
      if (scheme[1] && ['http', 'https', 'mailto'].indexOf(scheme[1]) == -1) return all; // Avoid XSS links
      this._state.md = true;
      const first = href.substring(0, 1);
      const target = ['/', '#'].indexOf(first) != -1 ? '' : ' target="_blank"';
      return '<a href="' + route.urlFor(href) + '"' + target + '>' + text + '</a>';
    });
  _nbsp(str) {
    return !str.length ? '&nbsp;' : str.replace(/^\s/, '&nbsp;').replace(/\s{2}/g, ' &nbsp;');
  _plainUrlToLink(str) {
    if (this._state.md) return str;
    const urlRe = RE.url || (RE.url = XRegExp(`(^|\\s) ( (?:http|https)://\\S+ | mailto:\\S+ )`, 'gx'));
    const endRe = RE.urlEnd || (RE.urlEnd = XRegExp('^(.*)([' + STOP + '])$'));
    return XRegExp.replace(str, urlRe, (all, b, url) => {
      const parts = XRegExp.exec(url, endRe) || [all, url, ''];
      return b + '<a href="' + parts[1] + '" target="_blank">' + parts[1].replace(/^mailto:/, '') + '</a>' + parts[2];
  _xmlEscape(str) {
    return str.replace(/[&<>']/g, (m) => XML_ESCAPE[m]);

Fixed code:
 - Fix XSS vulnerability in links
   Contributor: Pocas
   Reference: https://huntr.dev/bounties/4532a0ac-4e7c-4fcf-9fe3-630e132325c0
 - Add EXPERIMENTAL support for IRC color and text formatting #281 #600
 - Changed to hiding "https://" from generated links, but keep "http://"
 - Changed I18N.md() to be understand recursive tags #601
 - Replaced CONVOS_DEBUG=1 with CONVOS_LOG_LEVEL=trace
    .toBe('<a href="https://commons.wikimedia.org/wiki/File:HK_WCD_WC_%E7%81%A3%E4%BB%94_Wan_Chai_%E8%BB%92%E5%B0%BC%E8%A9%A9%E9%81%93_Hennessy_Road_tram_body_ads_Tsingtao_Brewery_August_2021_SS2.jpg" target="_blank">commons.wikimedia.org/wiki/File:HK_WCD_WC_%E7%81%A3%E4%BB%94_Wan_Chai_%E8%BB%92%E5%B0%BC%E8%A9%A9%E9%81%93_Hennessy_Road_tram_body_ads_Tsingtao_Brewery_August_2021_SS2.jpg</a>');
test('md - raw', () => {
  expect(i18n.md('> [not a link](https://convos.chat) `<a href="#cool" onclick=""></a>`', {raw: true}))
    .toBe('&gt; [not a link](https://convos.chat) `&lt;a href=&quot;#cool&quot; onclick=&quot;&quot;&gt;&lt;/a&gt;`');
});

test('md - whitespace', () => {
  expect(i18n.md('')).toBe('&nbsp;');
  expect(i18n.md('', {})).toBe('&nbsp;');
  expect(i18n.md('', {raw: true})).toBe('&nbsp;');
  expect(i18n.md(' f   b a      r ', {raw: true})).toBe('&nbsp;f &nbsp; b a &nbsp; &nbsp; &nbsp;r&nbsp;');
  expect(i18n.md('')).toBe('&nbsp;');
  expect(i18n.md('    ___ ___  _  ___   _____  ___'))
    .toBe('&nbsp; &nbsp; ___ ___ &nbsp;_ &nbsp;___ &nbsp; _____ &nbsp;___');
  expect(i18n.md('> Some quote'))
    .toBe('<blockquote>Some quote</blockquote>');
  expect(i18n.md('> escape <a href="#foo">bar</a>'))
    .toBe('<blockquote>escape &lt;a href=&quot;#foo&quot;&gt;bar&lt;/a&gt;</blockquote>');
});

test('md - code', () => {
  expect(i18n.md('> Some `code example` yeah'))
    .toBe('<blockquote>Some <code>code example</code> yeah</blockquote>');
  expect(i18n.md('Some `code with **[foo](#bar)**`'))
    .toBe('Some <code>code with **[foo](#bar)**</code>');
  expect(i18n.md('single `a` char'))
    .toBe('single <code>a</code> char');
  expect(i18n.md('is this \\`not code`, or..?'))
    .toBe('is this `not code`, or..?');
  expect(i18n.md('is this `not code`, or..?'))
    .toBe('is this <code>not code</code>, or..?');
  expect(i18n.md('not a `https://link.com`'))
    .toBe('not a <code>https://link.com</code>');
  expect(i18n.md('a regexp: `TShop\.Setup\(\s*([{](?>[^\\"{}]+|"(?>[^\\"]+|\\[\S\s])*"|\\[\S\s]|(?-1))*[}])`'))
    .toBe('a regexp: <code>TShop\.Setup\(\s*([{](?&gt;[^\\&quot;{}]+|&quot;(?&gt;[^\\&quot;]+|\\[\S\s])*&quot;|\\[\S\s]|(?-1))*[}])</code>');
  expect(i18n.md('kikuchi` changed nick to kikuchi```.'))
    .toBe('kikuchi` changed nick to kikuchi```.');
});

test('md - em, strong', () => {
  expect(i18n.md('> Some *em text* right'))
    .toBe('<blockquote>Some <em>em text</em> right</blockquote>');
  expect(i18n.md('Some **strong text** right'))
    .toBe('Some <strong>strong text</strong> right');
  expect(i18n.md('Some ***strong em text*** right'))
    .toBe('Some <em><strong>strong em text</strong></em> right');
  expect(i18n.md('> Some * em text* right'))
    .toBe('<blockquote>Some * em text* right</blockquote>');

  // Quotes should always be escaped - Pretect against XSS
  expect(i18n.md('Hey *foo* \'"**bar**"\' ***baz***!'))
    .toBe('Hey <em>foo</em> &apos;&quot;<strong>bar</strong>&quot;&apos; <em><strong>baz</strong></em>!');
});

test('md - colors', () => {
  expect(i18n.md('\x02bold text\x02')).toBe('<strong>bold text</strong>');
  expect(i18n.md('\x1ditalic text\x1d')).toBe('<em>italic text</em>');
  expect(i18n.md('\u00035colored text\x03')).toBe('<span style="color:brown">colored text</span>');
  expect(i18n.md('\u00034,12colored text and background\u0003')).toBe('<span style="color:red;background-color:lightblue">colored text and background</span>');
test('md - url', () => {
    .toBe('A link to <a href="https://convos.chat" target="_blank">convos.chat</a>, cool ey?');
  expect(i18n.md('A link to http://convos.chat, cool ey?'))
    .toBe('A link to <a href="http://convos.chat" target="_blank">http://convos.chat</a>, cool ey?');
    .toBe('<a href=\"https://ru.wikipedia.org/wiki/Участница:Gryllida/Черновик\" target=\"_blank\">ru.wikipedia.org/wiki/Участница:Gryllida/Черновик</a> last symbol shows as separate outside of the URL? do you reproduce the bug?');
    .toBe('[mojo] marcusramberg opened pull request #1894: Minor tweaks to Growing guide. - <a href=\"https://git.io/JD9ph\" target=\"_blank\">git.io/JD9ph</a>');
  // Protect against XSS
  expect(i18n.md('https://x."//onfocus="alert(document.domain)"//autofocus="" b="'))
    .toBe('<a href="https://x.&quot;//onfocus=&quot;alert(document.domain)&quot;//autofocus=&quot;" target="_blank">x.&quot;//onfocus=&quot;alert(document.domain)&quot;//autofocus=&quot;</a>&quot; b=&quot;');
test('md - markdown link', () => {
  expect(i18n.md('some [cool chat](https://convos.chat)'))
    .toBe('some <a href="https://convos.chat" target="_blank">cool chat</a>');
  expect(i18n.md('want to join #foo-1.2 #foo-bar href="#anchor" #foo.bar'))
    .toBe('want to join <a href="./%23foo-1.2">#foo-1.2</a> <a href="./%23foo-bar">#foo-bar</a> href=&quot;#anchor&quot; <a href="./%23foo.bar">#foo.bar</a>');
const ESCAPE = {'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&apos;', '"': '&quot;'};
const escape = (str, re = /[&<>'"]/g) => str.replace(re, (m) => ESCAPE[m]);
const nbsp = (str) => str.replace(/\s$/, '&nbsp;').replace(/^\s/, '&nbsp;').replace(/\s{2}/g, ' &nbsp;');
const tagPair = (tags) => [tags.map(n => `<${n}>`).join(''), tags.reverse().map(n => `</${n}>`).join('')];

const COLORS = {
   '0': 'white',
   '1': 'black',
   '2': 'blue',
   '3': 'green',
   '4': 'red',
   '5': 'brown',
   '6': 'magenta',
   '7': 'orange',
   '8': 'yellow',
   '9': 'lightgreen',
  '10': 'cyan',
  '11': 'lightcyan',
  '12': 'lightblue',
  '13': 'pink',
  '14': 'grey',
  '15': 'lightgrey',
};
    this._rules = this._makeRules();
    return !str.length ? '&nbsp;'
         : opt.raw ? nbsp(escape(str))
         : this.emojis.markup(nbsp(this._tagToHTML(this._makeTag(str))));
  _makeRules() {
    const rules = [];
    rules.push({tag: tagPair(['code']), re: /`(?=[^`\s])/, rules: [], handler: '_mdTag'});
    rules.push({tag: tagPair(['em', 'strong']), re: /\*\*\*(?=\S)/, rules, handler: '_mdTag'});
    rules.push({tag: tagPair(['strong']), re: /\*\*(?=\S)/, rules, handler: '_mdTag'});
    rules.push({tag: tagPair(['em']), re: /\*(?=\S)/, rules, handler: '_mdTag'});
    rules.push({tag: tagPair(['span']), re: /\x03\d{1,2}(?:,\d{1,2})?/, rules, handler: '_mdIrcColorFormatting'});
    rules.push({tag: tagPair(['span']), re: /[\x02\x1d\x1e\x1f\x11]/, rules, handler: '_mdIrcTextFormatting'});
    rules.push({tag: tagPair(['a']), re: /\[([a-zA-Z][^\]]+)\]\(([^)]+)\)/, rules: [], handler: '_mdLink'});
    rules.push({tag: tagPair(['a']), re: /\b(https?|mailto):\S+/, rules: [], handler: '_mdURL'});
    rules.push({tag: tagPair(['a']), re: /(?<=\s|^)#[a-zA-Z][\w.-]+(?=\W|$)/, rules: [], handler: '_mdChannelname'});
    return rules;
  _makeTag(str, rules = this._rules, depth = 0) {
    // blockquote
    if (depth == 0 && str.indexOf('> ') == 0) {
      return [tagPair(['blockquote']), {}, [this._makeTag(str.replace(/^>\s/, ''), rules, depth + 1)]];
    }

    const children = [];
    for (const rule of rules) {
      const match = str.match(rule.re);
      if (!match) continue;

      const tag = {
        attrs: {},
        after: str.substring(match.index + match[0].length),
        before: str.substring(0, match.index),
        captured: match[0],
        index: match.index,
        match,
        tag: rule.tag,
      };

      this[rule.handler](tag);
      if (typeof tag.content !== 'string') {
        str = tag.before + tag.captured + tag.after;
        continue;
      }

      if (tag.before.length) children.push(this._makeTag(tag.before, rules, depth + 1));
      children.push([tag.tag, tag.attrs, [this._makeTag(tag.content, rule.rules, depth + 1)]]);
      if (tag.after.length) children.push(this._makeTag(tag.after, rules, depth + 1));
      break;
    }

    return [null, {}, children.length ? children : [escape(str)]];
  _mdChannelname(tag) {
    tag.content = tag.captured;
    tag.attrs.href = './' + encodeURIComponent(tag.captured);
  // https://modern.ircdocs.horse/formatting.html
  _mdIrcColorFormatting(tag) {
    const end = tag.after.indexOf('\x03');
    if (end == -1) return;
    tag.content = tag.after.substring(0, end);
    tag.after = tag.after.substring(end + tag.captured.length);

    const style = [];
    const color = tag.captured.replace(/\x030?(\d{1,2}).*/, '$1');
    if (COLORS[color]) style.push('color:' + COLORS[color]);
    const background = tag.captured.replace(/.*,(\d{1,2}).*/, '$1');
    if (COLORS[background]) style.push('background-color:' + COLORS[background]);
    if (style.length) tag.attrs.style = style.join(';');
  // https://modern.ircdocs.horse/formatting.html
  _mdIrcTextFormatting(tag) {
    const end = tag.after.indexOf(tag.captured);
    if (end == -1) return;

    tag.content = tag.after.substring(0, end);
    tag.after = tag.after.substring(end + tag.captured.length);
    tag.tag = tag.captured == '\x02' ? tagPair(['strong'])
              : tag.captured == '\x1d' ? tagPair(['em'])
              : tag.captured == '\x1f' ? tagPair(['u'])
              : tag.captured == '\x11' ? tagPair(['code'])
              : tagPair(['span']);
  _mdLink(tag) {
    tag.content = tag.match[1];
    tag.attrs.href = escape(tag.match[2]);
    if (tag.match[2].match(/^\w+:/)) tag.attrs.target = '_blank';
  _mdTag(tag) {
    // Check if the matched character was escaped
    if (tag.before.match(/\\$/)) {
      tag.before = tag.before.replace(/\\$/, '');
      return;
    }
    const end = tag.after.indexOf(tag.captured);
    if (end == -1) return;
    tag.content = tag.after.substring(0, end);
    tag.after = tag.after.substring(end + tag.captured.length);
  }

  _mdURL(tag) {
    tag.captured = tag.captured.replace(/[,.:;!"\']$/, (after) => {
      tag.after = after[0] + tag.after;
      return '';

    tag.content = tag.captured.replace(/^(https|mailto):(\/\/)?/, '');
    tag.attrs.href = escape(tag.captured);
    tag.attrs.target = '_blank';
  _tagToHTML(tag) {
    if (typeof tag === 'string') return tag;

    const inner = typeof tag[2] === 'string' ? escape(tag[2]) : tag[2].map(n => this._tagToHTML(n)).join('');
    if (!tag[0]) return inner;

    const attrs = Object.keys(tag[1]).sort().map(k => `${k}="${tag[1][k]}"`).join(' ');
    const startTag = !attrs ? tag[0][0] : tag[0][0].replace(/>/, ' ' + attrs + '>');
    return startTag + inner + tag[0][1];