diff --git a/app/lib/shared/markdown.dart b/app/lib/shared/markdown.dart index 241cc60c8a..4874d36379 100644 --- a/app/lib/shared/markdown.dart +++ b/app/lib/shared/markdown.dart @@ -178,14 +178,19 @@ class _UnsafeUrlFilter extends html_parsing.TreeVisitor { void visitElement(html.Element element) { super.visitElement(element); - final isUnsafe = - _isUnsafe(element, 'a', 'href') || _isUnsafe(element, 'img', 'src'); + final isUnsafe = _isUnsafe(element, 'a', 'href') || + _isUnsafe(element, 'img', 'src', allowDataBase64: true); if (isUnsafe) { element.replaceWith(html.Text(element.text)); } } - bool _isUnsafe(html.Element element, String tag, String attr) { + bool _isUnsafe( + html.Element element, + String tag, + String attr, { + bool allowDataBase64 = false, + }) { if (element.localName != tag) { return false; } @@ -194,7 +199,14 @@ class _UnsafeUrlFilter extends html_parsing.TreeVisitor { return false; } final uri = Uri.tryParse(url); - if (uri == null || uri.isInvalid) { + var isInvalid = uri == null || uri.isInvalid; + if (allowDataBase64 && + uri != null && + uri.scheme == 'data' && + _isValidDataBase64ImageUrl(uri.path)) { + isInvalid = false; + } + if (isInvalid) { element.attributes.remove(attr); return true; } @@ -202,6 +214,15 @@ class _UnsafeUrlFilter extends html_parsing.TreeVisitor { } } +bool _isValidDataBase64ImageUrl(String value) { + // NOTE: This is only a high-level check. It doesn't decode the image bytes, + // and it doesn't check if the content is valid and matching the specified type. + if (RegExp(r'^image/[a-z0-9\+\-]+;base64,.*$').matchAsPrefix(value) == null) { + return false; + } + return true; +} + /// Rewrites relative URLs with the provided [urlResolverFn]. class _RelativeUrlRewriter extends html_parsing.TreeVisitor { final UrlResolverFn? urlResolverFn; diff --git a/app/test/shared/markdown_test.dart b/app/test/shared/markdown_test.dart index 2c3d7b2535..602de7503c 100644 --- a/app/test/shared/markdown_test.dart +++ b/app/test/shared/markdown_test.dart @@ -448,4 +448,26 @@ void main() { ]); }); }); + + group('data URLs', () { + final dataUrl = + 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; + + test('rejected in links', () { + final output = markdownToHtml('[link]($dataUrl)'); + expect(output, '
link
\n'); + }); + + test('accepted in image', () { + // TODO: also enable data: URLs in sanitize_html + final output = markdownToHtml(''); + expect(output, '