Stored XSS in HTML exports through embed URLs (#1568 fixed only the markdown path)
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:
{
"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:
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