Stored XSS in HTML exports through embed URLs (#1568 fixed only the markdown path)

Author: MildlyMeticulousCreated Jul 25, 2026Updated Jul 25, 2026
Labelsbugformat/html

Version

prime @ 0358045 (the commit that merged #1568)

Export format

HTML

Summary

#1568 fixed the masked-link XSS from #1567 by restricting the markdown link matchers to https?://. Embed URLs reach the same anchor sink but do not go through the markdown parser, so they were not covered. A javascript: URL in an embed still renders as a live href in HTML exports.

Affected sinks

DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml:

line sink
489 <a class="chatlog__embed-author-link" href="@embed.Author.Url">
507 <a class="chatlog__embed-title-link" href="@embed.Url">
609 <a class="chatlog__embed-author-link" href="@embed.Author.Url">
627 <a class="chatlog__embed-title-link" href="@embed.Url">

Embed.Parse and EmbedAuthor.Parse read url with GetNonWhiteSpaceStringOrNull() and apply no scheme check; the template guards only on !string.IsNullOrWhiteSpace(...). Razor encodes the value, which protects the attribute syntax but does nothing about the scheme — the same point #1567 made about the markdown path. These two fields are links rather than media, so unlike the embed image/icon fields there is no proxy_url fallback that would sanitise them.

Reproduction

embed.url and embed.author.url are only settable via a webhook or bot, not by a normal message, so the payload is delivered through a webhook posted to the channel:

json
{
  "embeds": [{
    "type": "rich",
    "title": "Click me (embed title link)",
    "url": "javascript:document.title='DCE-EMBED-TITLE-XSS'",
    "author": {
      "name": "Click me (embed author link)",
      "url": "javascript:document.title='DCE-EMBED-AUTHOR-XSS'"
    }
  }]
}

Export the channel to HTML, then grep the output:

href="javascript:document.title='DCE-EMBED-AUTHOR-XSS'"
href="javascript:document.title='DCE-EMBED-TITLE-XSS'"

Clicking either link in the exported page runs the script. I verified this against prime at 0358045 by building the CLI and serving the payload above from a local stand-in for the Discord API, so the export runs through the normal ChannelExporter path.

Threat model

Weaker than #1567, and worth stating plainly:

  • #1567 needed only the ability to post a message. This needs the ability to set an embed url, i.e. a webhook or bot in the channel being exported. That is still common, since Manage Webhooks is widely delegated and forwarded messages carry embeds in from elsewhere.
  • It requires the victim to click the crafted link in the export, whereas #1567's masked link was also a click. It does not execute during the export itself.

The person at risk is someone archiving a channel they do not control, which is a normal reason to run DCE. When the export is opened from disk the script runs in the file:// origin (read/exfiltrate the archived conversation, rewrite what the page shows). When the export is published to a web host — a common thing to do with these logs — it becomes stored XSS on that origin.

Suggestion

The parser is the wrong layer to fix this, which is why #1568 did not generalise: a regex was carrying the security property. Every href written to the export comes from a small number of sinks, so a single helper would cover the embed cases and anything added later:

csharp
private static string? SafeUrl(string? url) =>
    Uri.TryCreate(url, UriKind.Absolute, out var uri)
    && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
        ? url
        : null;

Apply it at each href/src sink and drop the anchor when it returns null. HtmlMarkdownVisitor.VisitLinkAsync could then rely on the same helper rather than on the three regexes.

Happy to open a PR if you'd like it done this way.


Unrelated hardening note while in this area: with --media, ExportAssetDownloader.DownloadAsync fetches embed image/icon URLs directly. Fetching third-party hosts is inherent to archiving embeds, so that is expected, but the downloader does not refuse loopback or private ranges (127.0.0.1, 169.254.169.254, 192.168.x), which can never be a legitimate embed image. Blocking those would be a cheap SSRF guard. Happy to fold it into the same PR or leave it out your call.

Source: Tyrrrz/DiscordChatExporter