Todoist Stored Cross Site Scripting


In May 2020 I identified a stored cross site scripting (XSS) vulnerability in the Todoist web application that affects all areas of the application which allow for Markdown-style ‘Text Formatting’ ( Anywhere on the Todoist application that it is possible to use Todoist’s Markdown-style text formatting, it is possible to break out of the tag and store a malicious JavaScript payload. I demonstrated the impact of this by creating an exploit that extracts the user’s API token.

This was fixed quickly by the Todoist team, initially mitigated by adding a CSP but later by fixing the root cause of the markdown interference.

Technical Details

The following proof of concept demonstrates a JavaScript payload that could be added to any text field in the Todoist web application, and will execute a harmless alert box (alert(document.domain)) on all major browsers when clicked on:

[xss]( javajavascript:script:{onerror=alert}throw document.domain//`xss`)

When a markdown link is added to the application using the formatting [inputtext](inputURL), it dynamically creates a simple <a href='inputURL' target=_blank>inputtext</a> tag.

So the classic XSS trick here is to use 'javascript:' in the URL, but in this case the string 'javascript:' is removed from the final tag. It turns out that the application doesn’t do this filtering recursively, so it is possible to use the string 'javajavascript:script:', which after the removal of the first 'javascript:', the application allows the URL to start with 'javascript:'. This allows for the ‘a’ tag to execute JavaScript when clicked, with this HTML:

<a href='javascript:alert()' target=_blank>inputtext</a>

However, the a tag had the property 'target=_blank', which meant that any attempt to follow a link would open in a new tab. I did a fair bit of research on this and could not figure out how to exploit it for XSS, so I used another vector I’d stumbled across, using other markdown to break out of the a tag.

When markdown is used for code formatting, with `inputcodetest` (two backticks), the following HTML is created:

<code class="code_inline">input</code>

Gareth Heyes (@) wrote an excellent post on XSS without parentheses

Secondly, although double quotes are filtered, preventing direct breakout of the ‘href’ attribute of the ‘a’ tag, it is possible to break out of the href by using code-style formatting within the URL section of the markdown. This allows for the removal of the attribute 'target=_blank', which can make it more difficult to execute scripts. Note that it is still possible to achieve cross site scripting without the need to remove 'target=_blank' from the a tag, it is just a more complex attack.

This XSS vulnerability can be chained with two other minor security weaknesses in the Todoist application and exploited to perform malicious actions against end-users, such as by connecting a malicious social account for full access to the victim’s account, or gaining access to a user’s private information by stealing their API key. With the user’s API key, it would be possible to monitor the victim’s personal information, such as todo list items, without their knowledge, as well as perform all other actions available through the API as the victim. The other minor security weaknesses are as follows:

  • The API key should not be constantly accessible from the user’s session, as it is effectively a session token without an expiry. It should be displayed only once, immediately, with the requirement of a password, and alert the user on its generation.
  • The lack of a Content Security Policy header means that the script is able to be executed without being blocked by the browser (see

As an example of this attack, the following payload will (on Chrome) grab a user’s API key and log it to the console when clicked:

[xss_test]( javajavascript:script:{onerror=eval}throw '=1;xhr=new XMLHttpRequest\x28\x29;xhr.onreadystatechange = function\x28\x29 {    if \x28this.readyState == 4 && this.status == 200\x29 {    api_key=xhr.responseText;    akey = api_key.match\x28/value=".*" readonly/g\x29[0];  alert\x28 \x27 api key grabbed. see console. \x27 \x29;console.log\x28 \x27Scraped API Key:\x27 + akey\x29    }};\x28\x27GET\x27,\x27/prefs/integrations\x27,true\x29;xhr.send\x28\x29;'//`xss`)

Although this exploit only works on Chrome, it would be relatively trivial to develop an exploit that works on all browsers and exfiltrate the token to an external, attacker-owned server. For clarity, a de-obsfucated view of the JavaScript exploit used is as follows:

throw '=1;
xhr=new XMLHttpRequest();
xhr.onreadystatechange = function() {    
    if (this.readyState == 4 && this.status == 200) {
        akey = api_key.match(/value=".*" readonly/g)[0];
        alert(' api key grabbed. see console. ');
        console.log('Scraped API Key:' + akey)    


25-05-2020 – Disclosed vulnerability to vendor
27-05-2020 – Response from vendor
16-07-2020 – Vulnerability seems to be mitigated by CSP
23-09-2020 – Vulnerability seems to be totally mitigated
24-09-2020 – Disclosed publicly through blog with permission from vendor