The one field nobody encoded: emoji attribute injection in HTML exports
Every user-controlled field in DiscordChatExporter's markdown visitor is HTML-encoded — except one. Custom emoji names are interpolated straight into alt and title attributes, in every HTML export, on every setting. Today the only thing standing between that and stored XSS is Discord's server-side emoji validation.
Analysis of DiscordChatExporter's HTML export pipeline (current release at time of report). Affected: ≤ 2.47.1; fixed in 2.47.2. Published as GHSA-r7qm-wg9p-pjfc under coordinated disclosure. This is a defense-in-depth finding: not exploitable end-to-end today, for reasons detailed below — and that is exactly why it was worth reporting.
Summary
When DiscordChatExporter renders an HTML export, custom emoji become <img> tags. The emoji's name and code are placed into alt and title attributes verbatim — no HtmlEncode(). Unlike the related CVE-2026-54682, this path is independent of the --markdown switch: it fires on the default configuration too. What currently prevents exploitation is external: Discord restricts emoji names to [a-zA-Z0-9_] on its servers. The exporter's own code offers no second wall.
Root cause: the asymmetry that gives it away
In HtmlMarkdownVisitor.cs, the emoji branch builds markup by interpolation:
// HtmlMarkdownVisitor.cs — VisitEmojiAsync()
buffer.Append($"""
<img loading="lazy" class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}"
title="{emoji.Code}"
src="{...}">
""");
One file over, the same visitor handles every other user-controlled value with care:
VisitTextAsync() → HtmlEncode(text.Text) ✓
VisitInlineCodeBlockAsync → HtmlEncode(inlineCodeBlock.Code) ✓
VisitLinkAsync() → HtmlEncode(link.Url) ✓
VisitMentionAsync() → HtmlEncode(display name) ✓
VisitTimestampAsync() → HtmlEncode(...) ✓
VisitEmojiAsync() → emoji.Name / emoji.Code — raw ✗
When one branch of a uniform pattern skips the control, that is an oversight, not a design decision. A quote character in an emoji name breaks out of alt and lands an event handler on the <img>:
// if a name of " onload="alert(1)" x=" ever reaches the renderer:
<img alt="" onload="alert(1)" x="" ...>
Why report a bug that "can't fire"
Because the guard is somebody else's. The conditions that would arm this are mundane: Discord relaxing emoji-name validation (Unicode emoji names are a foreseeable feature), a tampered export JSON processed offline, a future exporter feature ingesting emoji metadata from non-Discord sources. Defense-in-depth means the exporter must not silently rely on a third party's input policy — especially when every sibling field in the same file is encoded. The fix is one line per attribute:
alt="{WebUtility.HtmlEncode(emoji.Name)}"
title="{WebUtility.HtmlEncode(emoji.Code)}"
Currently mitigated by Discord's server-side emoji-name validation ([a-zA-Z0-9_] excludes ", <, >), so there is no working end-to-end exploit today — severity is Medium (CVSS 4.1), and we treat it as a hardening gap with a clear trigger condition, not a live exploit. If that validation ever relaxes, every HTML export becomes a stored-XSS carrier even with default settings.
Disclosure timeline
- 2026-08 — identified during our export-pipeline security review, alongside the encoding audit of the same file.
- 2026-08 — reported to the maintainer through a GitHub private security advisory.
- 2026-08 — CVE-2026-54681 assigned; fix shipped in 2.47.2; GHSA-r7qm-wg9p-pjfc published.
- 2026-08-13 — public write-up (this page).
References
DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs—VisitEmojiAsync()and the encoded sibling methods.- GHSA-r7qm-wg9p-pjfc — the published advisory.
- Related live issue in the same pipeline: CVE-2026-54682 (stored XSS with markdown disabled).