
Auditing Telegram Desktop's HTML Export for Incomplete-Escaping XSS
Why Telegram Desktop's HTML export is an XSS attack surface
I export chats more often than is healthy. Before a device wipe, before a support escalation, before I hand a laptop back — the whole conversation goes to disk as one HTML file. For years I filed that file under "inert", in the same drawer as PDFs.
It isn't inert. On 2026-09-15, gbhackers and CyberSecurityNews both published reports on an XSS-style flaw in Telegram Desktop's HTML export, where a poisoned export can lead to theft of entire chat histories. The headlines are blunt: "Telegram Desktop XSS Vulnerability Lets Attackers Steal Entire Chat Histories" and "Telegram Desktop Flaw Lets Attackers Steal Chat Messages Through Poisoned HTML Exports".
This post covers what those reports establish, the incomplete-escaping bug class behind them, a lab harness that reproduces the failure without touching a real client, and the fixes I would ship first. My position up front: the export button is an attack surface, not a convenience feature. Any desktop client that ships an embedded webview inherits browser-grade bug classes, and this one turns a file you generated yourself into an exfiltration path.
What the Telegram Desktop XSS reports confirm — and what they leave open
Here is the confirmed part, stated narrowly.
- Two outlets published on 2026-09-15: gbhackers (roughly 10:43 UTC) and CyberSecurityNews (roughly 09:47 UTC).
- Both describe a cross-site-scripting-style flaw tied to Telegram Desktop's HTML export.
- Both describe the outcome as theft of chat messages or entire chat histories.
- Both treat the client's handling of exported or injected HTML as the buggy component.
Now the part that matters just as much. At the time of writing, the following are not established:
- no CVE identifier
- no affected version range
- no named researcher or disclosure timeline
- no vendor advisory on a Telegram-controlled domain
- no confirmation that a patch shipped, and no fixed version
- no technical detail on the exact injection point or the exfiltration route
There's also a practical caveat about my links: they're Google News redirect URLs, not the publishers' canonical article URLs. I can cite the headline, publisher, and timestamp, but I can't point you at a stable primary document.
So this post treats the report as a bug class, not as a single reproducible CVE. That's not a dodge. The class is the part you can test against your own exporter, and it's the part that keeps regressing.
The bug class: incomplete HTML escaping across a trust boundary
The shape of the flaw is two steps. That's inference from how the reports describe it — I have not audited Telegram's exporter.
- Message content, which is attacker-controlled (anyone who can message you), is serialized into an HTML document by the client's exporter.
- The application later renders that HTML inside an embedded browser context that carries local privileges.
Textbook trust boundary violation. A message body is data. The exporter promotes it to markup, and a privileged renderer parses it as markup.
The claim I actually want to defend is narrower than "escaping was missing". Missing escaping is rare in shipped products. Incomplete escaping is common. A single escapeHtml() helper gets written once, applied to element text, and then the exporter grows fields: a link preview URL, a display name, a forward header, a file name, a caption. Every new field is a new context, and the helper was never context-aware to begin with.
Why the escaping context matters more than the word "escaping"
| Context | Characters that break out | What a generic escapeHtml() leaves exploitable |
|---|---|---|
| Element text | <, & | little, if <, >, &, ", ' are all encoded |
| Double-quoted attribute | " | attribute injection when quotes are not encoded |
| Unquoted attribute | space, ", ', backtick, <, >, = | almost everything; any whitespace terminates the value |
href / src | the scheme itself | javascript:, data:text/html, vbscript: |
<script> block | </script>, <!-- | entity encoding does not help; needs JSON escaping plus </script> handling |
style / CSS | }, ;, url(...) | CSS injection; behavior depends on the parser |
| SVG / MathML | <, &, foreignObject | different parser rules; generic sanitizers frequently miss these paths |
Two rows deserve the emphasis, because they're the ones teams get wrong. First, URLs: encoding < and > in an href does nothing about javascript:. Scheme allowlisting is the control, not escaping. Second, <script> blocks: HTML entity encoding is the wrong tool entirely there — the browser doesn't decode entities inside script data.
A naive exporter looks like this:
function escapeHtml(s) {
return String(s).replace(/</g, "<").replace(/>/g, ">");
}
function renderMessage(msg) {
// element text gets escaped; the attribute context does not
return `<p>${escapeHtml(msg.text)} <a href="${escapeHtml(msg.link)}">link</a></p>`;
}
// lab string, not a real payload
console.log(renderMessage({
text: "standup at 10?",
link: `" onmouseover="console.log(1)`,
}));
Run under Node 22.x, that prints:
<p>standup at 10? <a href="" onmouseover="console.log(1)">link</a></p>
The < and > escaping did its job on msg.text and was irrelevant for msg.link. The message body is now an event handler. That's the entire bug class in four lines.
Why a locally generated export file is not a safe origin
Once script runs inside the viewer, the reachable surface is bigger than the export document:
- The export document itself — every message in the file, plus anything else the exporter embedded.
- A cached message store, if the client keeps one on disk and the renderer can reach it.
localStorageandIndexedDB— file-backed origins aren't uniformly treated as opaque by every engine.- Preload bridges and IPC channels, the Electron-shaped part of the problem. A renderer with
window.bridgeor asend/invokesurface hands script execution a route into the main process.
The impact sentence from the reports — whole chat history — needs one of those routes. The export file alone gives you the messages inside it. Getting to entire history implies a storage API or a bridge/IPC method. That mechanism is likely what's happening, but the reports I have don't describe it, so I'm marking it as inference rather than fact.
Which is why "it's only a local file" is a bad argument. A local file loaded into a privileged renderer is the whole problem, not an extenuating circumstance.
Reproducing the incomplete-escaping XSS class in a lab
Scope first: everything below is a synthetic harness I wrote to test the escaping-context failure mode. It is not Telegram's code, it does not run Telegram Desktop, and it does not touch any real client or account. The harness is two files — an exporter and a viewer.
// lab/export.js
const fs = require("fs");
const escapeText = (s) =>
String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
const messages = [
{ from: "alice", text: "standup at 10?", link: "https://example.test/standup" },
{ from: "mallory", text: "lab string, not a payload", link: `" onmouseover="console.log('handler fired')` },
];
const html = `<!doctype html><meta charset="utf-8"><title>export</title>
<h1>Chat export</h1>
${messages
.map((m) => `<p class="${m.from}">${escapeText(m.text)} <a href="${escapeText(m.link)}">link</a></p>`)
.join("\n")}`;
fs.writeFileSync("chat-export.html", html);
// lab/viewer.js (Playwright, bundled Chromium)
const { chromium } = require("playwright");
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
page.on("console", (m) => console.log("[page]", m.text()));
await page.goto("file://" + process.cwd() + "/chat-export.html");
await page.mouse.move(10, 10); // fire the hover handler
console.log("origin:", await page.evaluate(() => location.origin));
console.log("localStorage:", await page.evaluate(() => typeof localStorage));
console.log("indexedDB:", await page.evaluate(() => typeof indexedDB));
console.log("bridge:", await page.evaluate(() => typeof window.bridge));
await browser.close();
})();
Observed on Node 22.x with Chromium 128, macOS:
$ node lab/viewer.js
[page] handler fired
origin: file://
localStorage: object
indexedDB: object
bridge: undefined
Three things to read from that output. The handler fired from a message body — the class reproduces. The origin is file://, which Chromium reports as the scheme (Firefox reports null for the same document), so origin-based isolation is not the safety net people assume. And bridge: undefined only because my harness has no preload script; in a real Electron client with a preload bridge, that line decides whether "chat history theft" is possible or just "this file's contents leaked".
Auditing an existing HTML export for injected markup
You don't need a harness to check a file. Two greps.
$ grep -nE '<script|on[a-z]+=|javascript:|data:text/html' chat-export.html
12:<p class="mallory">lab string, not a payload <a href="" onmouseover="console.log('handler fired')">link</a></p>
Then check whether anything restricts what that file is allowed to do:
$ grep -c 'http-equiv="Content-Security-Policy"' chat-export.html
0
An export with an injected attribute and no restrictive policy is worth a deeper look. That's all these greps tell you — they don't prove exploitability, because the handler still has to reach something valuable. Keep the finding neutral until you've traced the route.
Do not open untrusted .html exports inside the chat client's own viewer to "see what happens". If the renderer has a bridge or a message store, you just gave the file exactly the privileges it was waiting for.
Defenses that hold: containment first, then escaping
Fix order matters, and I'd argue against the instinct to patch the escaping string first.
Containment first, because containment is context-independent and escaping is not. A restrictive policy on the viewer document caps the blast radius even when an escaping bug exists:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'none'; style-src 'none'; img-src data:">
<iframe src="chat-export.html" sandbox referrerpolicy="no-referrer"></iframe>
Notes on that snippet, because both lines have caveats. The CSP has to sit on the document that renders the untrusted HTML, not the app shell — a shell-level policy that doesn't cover the export viewer protects nothing. A meta CSP also can't use frame-ancestors or sandbox; if your app serves the viewer over a local scheme, send it as a real header instead. And sandbox with no values means no allow-scripts and no allow-same-origin, which is the point.
For the desktop shell, the Electron controls are the standard set, and they only work together: contextIsolation: true, nodeIntegration: false, sandbox: true, no preload bridge on the viewer window, will-navigate blocked, and setWindowOpenHandler returning { action: "deny" } for anything the export viewer tries to open.
Then fix the escaping, by not building markup as strings at all:
const ALLOWED_SCHEMES = new Set(["https:", "tg:"]);
function safeHref(raw) {
const url = new URL(raw, "https://export.invalid/");
const relative = url.origin === "https://export.invalid";
return relative || ALLOWED_SCHEMES.has(url.protocol) ? url.href : null;
}
function renderMessage(msg) {
const p = document.createElement("p");
p.textContent = msg.text;
const href = safeHref(msg.link);
if (href) {
const a = document.createElement("a");
a.textContent = href;
a.rel = "noreferrer noopener";
a.href = href;
p.append(a);
}
return p;
}textContent and setAttribute can't be tricked by a context switch, because there is no context to switch — you never produce a string for the parser to re-read. If you must accept foreign HTML, run it through an allowlist sanitizer such as DOMPurify with an explicit, reviewed config, and treat any widening of that config as a code change that needs review.
Why escaping-only fixes keep regressing: every field added to the exporter is a new context. The day someone adds a "forwarded from" header, a file caption, or a link preview title, the helper is wrong again, and nothing in the test suite notices because the tests cover the fields that existed when they were written.
And the cheapest fix of all: if a user doesn't need rich formatting, export plain text or CSV. That deletes the bug class instead of defending against it.
JavaScript escape-and-sanitize audit checklist
| Check | What breaks |
|---|---|
| Unquoted attribute values in generated markup | any whitespace or quote terminates the value and starts a new attribute |
href/src scheme allowlisting (https, tg, relative only) | javascript:, data:text/html, blob:, vbscript: pass through encoding |
| Template-literal interpolation of untrusted values into HTML strings | the exact context-confusion bug above |
innerHTML += and insertAdjacentHTML | re-parses the string, re-runs handlers, wipes state you thought you set |
document.write on the viewer | same parsing risk plus it resets the document |
JSON embedded in a <script> block without </script> and <!-- handling | the string closes the script element early |
| Entity or percent double-decoding | &#x3c; becomes < on the second pass |
| Charset mismatch between exporter and viewer | different byte-to-character mapping lets < through |
| A Markdown-to-HTML step that re-introduces raw HTML | sanitizing before the converter, not after, is a no-op |
DOMPurify ALLOWED_* / ADD_ATTR drift | the allowlist widens over time and nobody reviews the diff |
| CSP that exists in the app shell but not the export viewer | zero protection on the document that renders untrusted HTML |
srcdoc built by interpolation | attribute context, full document privileges |
| SVG/MathML routed through an HTML-only sanitizer | separate parser rules, separate bypass surface |
What I confirmed vs what I did not test
Confirmed
- Two reports on 2026-09-15 (gbhackers, CyberSecurityNews) describing an XSS-style flaw in Telegram Desktop's HTML export leading to chat-history theft.
- The escaping-context failure mode, reproduced in my own synthetic harness with the output shown above.
- The sample export contains an injected event-handler attribute and no
Content-Security-Policymeta tag, per the greps above.
Not tested
- Telegram Desktop itself — no binary reviewed, no traffic captured, no source read.
- The exact vulnerable code path, and whether the injection is in the exporter, the viewer, or both.
- Any affected version range.
- Whether a patch has shipped, and in which version.
- The precise bridge or storage route used to reach full chat history.
The public details are too thin to confirm a specific CVE, and I wouldn't try to guess one.
Further Reading
- gbhackers, "Telegram Desktop XSS Vulnerability Lets Attackers Steal Entire Chat Histories", 2026-09-15 — news coverage, available only as a Google News redirect URL in the material I have.
- CyberSecurityNews, "Telegram Desktop Flaw Lets Attackers Steal Chat Messages Through Poisoned HTML Exports", 2026-09-15 — news coverage, same caveat.
- Telegram Desktop releases — check here for patch status, since no advisory was provided.
- Electron security checklist — the official hardening guidance for
contextIsolation,sandbox, and navigation controls. - DOMPurify — the sanitizer referenced above, plus its config documentation.
- OWASP XSS Prevention Cheat Sheet — the context-by-context rule table this post's first table is modelled on.
- MDN: Content-Security-Policy — directive reference, including what a
metaCSP cannot express.
No CVE record and no vendor advisory are linked above, because none was provided in the source material, and inventing either would be worse than leaving the gap visible.


