From dca3eafc11afa2b885620bb78292a22f94a4af44 Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Thu, 23 Jul 2026 07:03:06 -0700 Subject: [PATCH 1/3] markdown: escape alt text in images --- pkgs/markdown/CHANGELOG.md | 1 + .../lib/src/inline_syntaxes/image_syntax.dart | 3 ++- pkgs/markdown/lib/src/util.dart | 11 ++++++++ pkgs/markdown/test/markdown_test.dart | 26 +++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/pkgs/markdown/CHANGELOG.md b/pkgs/markdown/CHANGELOG.md index 5a4988e49..6cf1848fa 100644 --- a/pkgs/markdown/CHANGELOG.md +++ b/pkgs/markdown/CHANGELOG.md @@ -12,6 +12,7 @@ * Optimize email autolink regex parsing in `AutolinkExtensionSyntax` by bounding quantifiers to RFC limits, improving performance on inputs with long sequences of dots or alphanumeric characters. +* Escape image description text when assigning it to the `alt` attribute. ## 7.3.1 diff --git a/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart b/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart index 031adf915..126acc3f3 100644 --- a/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart +++ b/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart @@ -24,7 +24,7 @@ class ImageSyntax extends LinkSyntax { element.attributes['src'] = normalizeLinkDestination( escapePunctuation(destination), ); - element.attributes['alt'] = children.map((node) { + final alt = children.map((node) { // See https://spec.commonmark.org/0.30/#image-description. // An image description may contain links. Fetch text from the alt // attribute if this nested link is an image. @@ -33,6 +33,7 @@ class ImageSyntax extends LinkSyntax { } return node.textContent; }).join(); + element.attributes['alt'] = escapeAttributeCharactersValue(alt); if (title != null && title.isNotEmpty) { element.attributes['title'] = normalizeLinkTitle(title); } diff --git a/pkgs/markdown/lib/src/util.dart b/pkgs/markdown/lib/src/util.dart index 724879ea1..81f003980 100644 --- a/pkgs/markdown/lib/src/util.dart +++ b/pkgs/markdown/lib/src/util.dart @@ -23,6 +23,17 @@ String escapeHtml(String html, {bool escapeApos = true}) => HtmlEscape( String escapeHtmlAttribute(String text) => const HtmlEscape(HtmlEscapeMode.attribute).convert(text); +/// Escapes raw `"` (double quote), `'` (single quote), `<` (less than) and +/// `>` (greater than) characters. +/// +/// Unlike [escapeHtmlAttribute], this does not escape `&` (ampersand) to +/// avoid double-escaping already-escaped HTML entities. +String escapeAttributeCharactersValue(String text) => text + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); + /// "Normalizes" a link label, according to the [CommonMark spec]. /// /// [CommonMark spec] https://spec.commonmark.org/0.30/#link-label diff --git a/pkgs/markdown/test/markdown_test.dart b/pkgs/markdown/test/markdown_test.dart index 787066124..23335190a 100644 --- a/pkgs/markdown/test/markdown_test.dart +++ b/pkgs/markdown/test/markdown_test.dart @@ -146,6 +146,32 @@ void main() async { ''', ); + validateCore( + 'Image alt attribute breakout XSS', + '![](bad)', + '

<b x=" onerror=alert(1) y=">

\n', + inlineSyntaxes: [InlineHtmlSyntax()], + ); + + validateCore( + 'Image alt attribute escaping ampersand and quotes', + '![a & " b](bad)', + '

a & " b

\n', + ); + + validateCore( + 'Image alt attribute double-encoded entity', + '![&amp;](bad)', + '

&amp;

\n', + ); + + validateCore( + 'Image alt attribute with raw HTML tag under GFM', + '![foo bar](bad)', + '

foo <a> bar

\n', + inlineSyntaxes: [InlineHtmlSyntax()], + ); + validateCore( 'Unicode ellipsis as punctuation', ''' From e7f4ddbbb8480808877e85073f27b007db030f8d Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Thu, 23 Jul 2026 14:08:54 -0700 Subject: [PATCH 2/3] feedback --- .../lib/src/inline_syntaxes/image_syntax.dart | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart b/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart index 126acc3f3..8b9dca516 100644 --- a/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart +++ b/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart @@ -24,15 +24,18 @@ class ImageSyntax extends LinkSyntax { element.attributes['src'] = normalizeLinkDestination( escapePunctuation(destination), ); - final alt = children.map((node) { - // See https://spec.commonmark.org/0.30/#image-description. - // An image description may contain links. Fetch text from the alt - // attribute if this nested link is an image. - if (node is Element && node.tag == 'img') { - return node.attributes['alt']; - } - return node.textContent; - }).join(); + final alt = children + .map((node) { + // See https://spec.commonmark.org/0.30/#image-description. + // An image description may contain links. Fetch text from the alt + // attribute if this nested link is an image. + if (node is Element && node.tag == 'img') { + return node.attributes['alt']; + } + return node.textContent; + }) + .nonNulls + .join(); element.attributes['alt'] = escapeAttributeCharactersValue(alt); if (title != null && title.isNotEmpty) { element.attributes['title'] = normalizeLinkTitle(title); From 245f765723a1a41038ed7c5ff81c31eb428d2b75 Mon Sep 17 00:00:00 2001 From: Sam Rawlins Date: Mon, 27 Jul 2026 21:03:15 -0700 Subject: [PATCH 3/3] simplify --- pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart b/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart index 8b9dca516..345869aa2 100644 --- a/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart +++ b/pkgs/markdown/lib/src/inline_syntaxes/image_syntax.dart @@ -29,8 +29,8 @@ class ImageSyntax extends LinkSyntax { // See https://spec.commonmark.org/0.30/#image-description. // An image description may contain links. Fetch text from the alt // attribute if this nested link is an image. - if (node is Element && node.tag == 'img') { - return node.attributes['alt']; + if (node case Element(tag: 'img', attributes: {'alt': final alt})) { + return alt; } return node.textContent; })