From 0fda3d872b0ab09784b78312fd03ca26dd03969e Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 23 Oct 2024 13:44:10 +0700 Subject: [PATCH 01/23] Custom allow attributes & tags --- sanitize_html/lib/sanitize_html.dart | 4 ++++ sanitize_html/lib/src/sane_html_validator.dart | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/sanitize_html/lib/sanitize_html.dart b/sanitize_html/lib/sanitize_html.dart index dc298d48..ba3b159d 100644 --- a/sanitize_html/lib/sanitize_html.dart +++ b/sanitize_html/lib/sanitize_html.dart @@ -76,10 +76,14 @@ String sanitizeHtml( bool Function(String)? allowElementId, bool Function(String)? allowClassName, Iterable? Function(String)? addLinkRel, + final List? allowAttributes, + final List? allowTags, }) { return SaneHtmlValidator( allowElementId: allowElementId, allowClassName: allowClassName, addLinkRel: addLinkRel, + allowAttributes: allowAttributes, + allowTags: allowTags, ).sanitize(htmlString); } diff --git a/sanitize_html/lib/src/sane_html_validator.dart b/sanitize_html/lib/src/sane_html_validator.dart index 32e39a81..516011b5 100644 --- a/sanitize_html/lib/src/sane_html_validator.dart +++ b/sanitize_html/lib/src/sane_html_validator.dart @@ -212,11 +212,15 @@ class SaneHtmlValidator { final bool Function(String)? allowElementId; final bool Function(String)? allowClassName; final Iterable? Function(String)? addLinkRel; + final List? allowAttributes; + final List? allowTags; SaneHtmlValidator({ required this.allowElementId, required this.allowClassName, required this.addLinkRel, + required this.allowAttributes, + required this.allowTags, }); String sanitize(String htmlString) { @@ -228,7 +232,8 @@ class SaneHtmlValidator { void _sanitize(Node node) { if (node is Element) { final tagName = node.localName!.toUpperCase(); - if (!_allowedElements.contains(tagName)) { + if (!_allowedElements.contains(tagName) + && !(allowTags?.contains(tagName.toLowerCase()) ?? false)) { node.remove(); return; } @@ -269,6 +274,8 @@ class SaneHtmlValidator { } bool _isAttributeAllowed(String tagName, String attrName, String value) { + if (allowAttributes?.contains(attrName.toLowerCase()) == true) return true; + if (_alwaysAllowedAttributes.contains(attrName)) return true; // Special validators for special attributes on special tags (href/src/cite) From c663ad93a659fce3c0d209a048fa93c6465ebedc Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 23 Oct 2024 14:29:01 +0700 Subject: [PATCH 02/23] Add validate base64 image tag --- sanitize_html/lib/sanitize_html.dart | 4 +- .../lib/src/sane_html_validator.dart | 13 +++++- .../test/validate_base64_image_test.dart | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 sanitize_html/test/validate_base64_image_test.dart diff --git a/sanitize_html/lib/sanitize_html.dart b/sanitize_html/lib/sanitize_html.dart index ba3b159d..fb4a6134 100644 --- a/sanitize_html/lib/sanitize_html.dart +++ b/sanitize_html/lib/sanitize_html.dart @@ -76,8 +76,8 @@ String sanitizeHtml( bool Function(String)? allowElementId, bool Function(String)? allowClassName, Iterable? Function(String)? addLinkRel, - final List? allowAttributes, - final List? allowTags, + List? allowAttributes, + List? allowTags, }) { return SaneHtmlValidator( allowElementId: allowElementId, diff --git a/sanitize_html/lib/src/sane_html_validator.dart b/sanitize_html/lib/src/sane_html_validator.dart index 516011b5..b4554728 100644 --- a/sanitize_html/lib/src/sane_html_validator.dart +++ b/sanitize_html/lib/src/sane_html_validator.dart @@ -177,6 +177,15 @@ bool _validUrl(String url) { } } +bool validateBase64Image(String base64String) { + try { + final regex = RegExp(r'^data:image\/(png|jpeg|jpg|gif|bmp|svg\+xml);base64,[A-Za-z0-9+/]+={0,2}$'); + return regex.hasMatch(base64String); + } catch (e) { + return false; + } +} + final _citeAttributeValidator = { 'cite': _validUrl, }; @@ -187,8 +196,8 @@ final _elementAttributeValidators = 'href': _validLink, }, 'IMG': { - 'src': _validUrl, - 'longdesc': _validUrl, + 'src': (url) => _validUrl(url) || validateBase64Image(url), + 'longdesc': (url) => _validUrl(url) || validateBase64Image(url), }, 'DIV': { 'itemscope': _alwaysAllowed, diff --git a/sanitize_html/test/validate_base64_image_test.dart b/sanitize_html/test/validate_base64_image_test.dart new file mode 100644 index 00000000..33d34271 --- /dev/null +++ b/sanitize_html/test/validate_base64_image_test.dart @@ -0,0 +1,46 @@ +import 'package:sanitize_html/src/sane_html_validator.dart'; +import 'package:test/test.dart'; + +void main() { + group('validateBase64Image', () { + test('Valid Base64 PNG image string', () { + String validBase64PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + expect(validateBase64Image(validBase64PNG), isTrue); + }); + + test('Valid Base64 JPEG image string', () { + String validBase64JPEG = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAAAAAA'; + expect(validateBase64Image(validBase64JPEG), isTrue); + }); + + test('Invalid Base64 image string (missing data:image/)', () { + String invalidBase64 = 'base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + expect(validateBase64Image(invalidBase64), isFalse); + }); + + test('Invalid Base64 image string (not base64 encoded)', () { + String invalidBase64 = 'data:image/png;notabase64string'; + expect(validateBase64Image(invalidBase64), isFalse); + }); + + test('Valid Base64 SVG image string', () { + String validBase64SVG = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDov'; + expect(validateBase64Image(validBase64SVG), isTrue); + }); + + test('Invalid Base64 image string (wrong image type)', () { + String invalidBase64Type = 'data:image/tiff;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + expect(validateBase64Image(invalidBase64Type), isFalse); + }); + + test('Empty string', () { + String emptyString = ''; + expect(validateBase64Image(emptyString), isFalse); + }); + + test('Non-image Base64 string', () { + String nonImageBase64 = 'data:text/plain;base64,dGVzdA=='; // Plain text Base64-encoded + expect(validateBase64Image(nonImageBase64), isFalse); + }); + }); +} From 01c37cde93640add2291655ef36d4949aff9dade Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 23 Oct 2024 18:58:22 +0700 Subject: [PATCH 03/23] Add validate CID source for image tag --- .../lib/src/sane_html_validator.dart | 25 ++++++++++++++++--- .../test/validate_cid_image_test.dart | 18 +++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 sanitize_html/test/validate_cid_image_test.dart diff --git a/sanitize_html/lib/src/sane_html_validator.dart b/sanitize_html/lib/src/sane_html_validator.dart index b4554728..a97f3265 100644 --- a/sanitize_html/lib/src/sane_html_validator.dart +++ b/sanitize_html/lib/src/sane_html_validator.dart @@ -14,6 +14,7 @@ import 'package:html/dom.dart'; import 'package:html/parser.dart' as html_parser; +import 'package:meta/meta.dart'; final _allowedElements = { 'H1', @@ -177,7 +178,7 @@ bool _validUrl(String url) { } } -bool validateBase64Image(String base64String) { +bool _validBase64Image(String base64String) { try { final regex = RegExp(r'^data:image\/(png|jpeg|jpg|gif|bmp|svg\+xml);base64,[A-Za-z0-9+/]+={0,2}$'); return regex.hasMatch(base64String); @@ -186,6 +187,24 @@ bool validateBase64Image(String base64String) { } } +bool _validCIDImage(String cidString) { + try { + return cidString.startsWith('cid:'); + } catch (e) { + return false; + } +} + +@visibleForTesting +bool validateBase64Image(String base64String) => _validBase64Image(base64String); + +@visibleForTesting +bool validateCIDImage(String cidString) => _validCIDImage(cidString); + +bool _validImageSource(String url) { + return _validUrl(url) || _validBase64Image(url) || validateCIDImage(url); +} + final _citeAttributeValidator = { 'cite': _validUrl, }; @@ -196,8 +215,8 @@ final _elementAttributeValidators = 'href': _validLink, }, 'IMG': { - 'src': (url) => _validUrl(url) || validateBase64Image(url), - 'longdesc': (url) => _validUrl(url) || validateBase64Image(url), + 'src': _validImageSource, + 'longdesc': _validImageSource, }, 'DIV': { 'itemscope': _alwaysAllowed, diff --git a/sanitize_html/test/validate_cid_image_test.dart b/sanitize_html/test/validate_cid_image_test.dart new file mode 100644 index 00000000..8cc0879a --- /dev/null +++ b/sanitize_html/test/validate_cid_image_test.dart @@ -0,0 +1,18 @@ +import 'package:sanitize_html/src/sane_html_validator.dart'; +import 'package:test/test.dart'; + +void main() { + group('validateCIDImage', () { + test('returns true for valid cid string', () { + expect(validateCIDImage('cid:12345'), true); + }); + + test('returns false for string without cid', () { + expect(validateCIDImage('https://example.com/image.png'), false); + }); + + test('returns false for empty string', () { + expect(validateCIDImage(''), false); + }); + }); +} From fda32cde4d4baadaa988477f498ab6622ee79987 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 24 Oct 2024 10:37:59 +0700 Subject: [PATCH 04/23] Allow `id` & `class` attribute --- sanitize_html/lib/src/sane_html_validator.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sanitize_html/lib/src/sane_html_validator.dart b/sanitize_html/lib/src/sane_html_validator.dart index a97f3265..caae2821 100644 --- a/sanitize_html/lib/src/sane_html_validator.dart +++ b/sanitize_html/lib/src/sane_html_validator.dart @@ -268,9 +268,11 @@ class SaneHtmlValidator { node.attributes.removeWhere((k, v) { final attrName = k.toString(); if (attrName == 'id') { - return allowElementId == null || !allowElementId!(v); + return allowAttributes?.contains('id') != true && + (allowElementId == null || !allowElementId!(v)); } if (attrName == 'class') { + if (allowAttributes?.contains('class') == true) return false; if (allowClassName == null) return true; node.classes.removeWhere((cn) => !allowClassName!(cn)); return node.classes.isEmpty; From efa2b4e6d691638d14acd99256dd853d6cf4fbd2 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 3 Dec 2025 02:21:07 +0700 Subject: [PATCH 05/23] feat(sanitize-html): add secure, high-performance HTML sanitization engine --- .../benchmark/fixtures/large_email_1.html | 219 ++++ .../benchmark/fixtures/large_email_2.html | 291 ++++++ .../benchmark/sanitize_benchmark.dart | 67 ++ sanitize_html/lib/src/attribute_policy.dart | 98 ++ sanitize_html/lib/src/css_sanitizer.dart | 71 ++ .../lib/src/html_sanitize_config.dart | 384 +++++++ sanitize_html/lib/src/node_sanitizer.dart | 226 ++++ .../lib/src/sane_html_validator.dart | 299 +----- sanitize_html/lib/src/url_validators.dart | 44 + sanitize_html/test/attribute_policy_test.dart | 54 + sanitize_html/test/css_sanitizer_test.dart | 36 + .../test/html_sanitize_config_test.dart | 28 + sanitize_html/test/node_sanitizer_test.dart | 82 ++ .../test/sane_html_validator_test.dart | 984 ++++++++++++++++++ sanitize_html/test/sanitize_html_test.dart | 14 - sanitize_html/test/url_validators_test.dart | 116 +++ .../test/validate_base64_image_test.dart | 46 - .../test/validate_cid_image_test.dart | 18 - 18 files changed, 2719 insertions(+), 358 deletions(-) create mode 100644 sanitize_html/benchmark/fixtures/large_email_1.html create mode 100644 sanitize_html/benchmark/fixtures/large_email_2.html create mode 100644 sanitize_html/benchmark/sanitize_benchmark.dart create mode 100644 sanitize_html/lib/src/attribute_policy.dart create mode 100644 sanitize_html/lib/src/css_sanitizer.dart create mode 100644 sanitize_html/lib/src/html_sanitize_config.dart create mode 100644 sanitize_html/lib/src/node_sanitizer.dart create mode 100644 sanitize_html/lib/src/url_validators.dart create mode 100644 sanitize_html/test/attribute_policy_test.dart create mode 100644 sanitize_html/test/css_sanitizer_test.dart create mode 100644 sanitize_html/test/html_sanitize_config_test.dart create mode 100644 sanitize_html/test/node_sanitizer_test.dart create mode 100644 sanitize_html/test/sane_html_validator_test.dart create mode 100644 sanitize_html/test/url_validators_test.dart delete mode 100644 sanitize_html/test/validate_base64_image_test.dart delete mode 100644 sanitize_html/test/validate_cid_image_test.dart diff --git a/sanitize_html/benchmark/fixtures/large_email_1.html b/sanitize_html/benchmark/fixtures/large_email_1.html new file mode 100644 index 00000000..97a9104b --- /dev/null +++ b/sanitize_html/benchmark/fixtures/large_email_1.html @@ -0,0 +1,219 @@ + + + + + + + + + + + +

<div class="gmail_quote"><div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>From: <strong class="gmail_sendername" dir="auto">Polkadot Newsletter</strong> <span dir="auto">&lt;news@polkadot.network&gt;</span><br>Date: Fri, 29 Sep 2023 at 22:06<br>Subject: 📢 USDC on Polkadot | 8x Scalability Update | Blockspace Explained<br>To:  &lt;<a href="mailto:hoangdat.pham2911@gmail.com" target="_blank" rel="noreferrer" class="tmail-tooltip">hoangdat.pham2911@gmail.com <span class="tooltiptext">mailto:hoangdat.pham2911@gmail.com</span></a>&gt;<br></div><br><br><u></u>

+

    

+


+


+


+


+


+


+


+


+


+


+


+


+


+


+


+

  <div id="m_403900111212837041hs_body" bgcolor="#FFFFFF" style="margin:0!important;padding:0!important;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word">

+


+

<div id="m_403900111212837041preview_text" style="display:none;font-size:1px;color:#ffffff;max-height:0px;max-width:0px;opacity:0;overflow:hidden">The wait is finally over for the popular USDC stablecoin on Polkadot.&nbsp; Circle began the process of launching native USDC in the Polkadot ecosystem on September 19th, eliminating the need to use bridged versions of the stablecoin.&nbsp;</div>

+


+


+

    <div style="background-color:#ffffff" bgcolor="#ffffff">

+

      <table cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse;margin:0;padding:0;width:100%!important;min-width:320px!important;height:100%!important" width="100%" height="100%">

+

        <tbody><tr>

+

          <td valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word">

+

            <div id="m_403900111212837041hs_cos_wrapper_main" style="color:inherit;font-size:inherit;line-height:inherit">  <div id="m_403900111212837041section-4" class="m_403900111212837041hse-section" style="padding-left:10px;padding-right:10px;padding-top:20px;padding-bottom:20px">

+


+


+


+

      <div class="m_403900111212837041hse-column-container" style="min-width:280px;max-width:600px;width:100%;Margin-left:auto;Margin-right:auto;border-collapse:collapse;border-spacing:0;background-color:#ffffff;padding-top:30px" bgcolor="#FFFFFF">

+


+


+


+


+


+

<div id="m_403900111212837041column-4-0" class="m_403900111212837041hse-column m_403900111212837041hse-size-12">

+

  <div id="m_403900111212837041hs_cos_wrapper_module_16908974233841" style="color:inherit;font-size:inherit;line-height:inherit"><table width="100%" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse">

+

  <tbody>

+

    <tr>

+

      <td align="center" valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;color:#073763;word-break:break-word;text-align:center;padding:10px 20px;font-size:0px">

+

        <img alt="Polkadot Newsletter Banner" src="https://hs-7592558.f.hubspotemail.net/hub/7592558/hubfs/unnamed.png?width=1120&amp;upscale=true&amp;name=unnamed.png" style="outline:none;text-decoration:none;max-width:100%;font-size:16px; display:inline;height:auto;" width="560" align="middle" loading="lazy">

+

      </td>

+

    </tr>

+

  </tbody>

+

</table></div>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16908974512503" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16908974512503_" style="color:inherit;font-size:inherit;line-height:inherit"><p style="line-height:125%;font-weight:bold"><span style="color:#000000">In today's edition:</span></p>

+

<ul style="line-height:175%">

+

<li style="line-height:125%;text-align:left" align="left"><span style="color:#000000">USDC lands on Polkadot<br></span></li>

+

<li style="line-height:125%;text-align:left" align="left"><span style="color:#000000">8x scalability update incoming!<br></span></li>

+

<li style="line-height:125%;text-align:left" align="left"><span style="color:#000000">What the heck is blockspace?<br></span></li>

+

<li style="line-height:125%;text-align:left" align="left"><span style="color:#000000">News from the ecosystem</span></li>

+

</ul></div></div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px 0px"><div id="m_403900111212837041hs_cos_wrapper_module_16909008726461" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16909008726461_" style="color:inherit;font-size:inherit;line-height:inherit"><h2 style="margin:0;line-height:125%;font-size:22px;text-align:left" align="left"><span style="color:#000000">USDC Lands on Polkadot 🪙<br></span></h2></div></div></td></tr></tbody></table>

+

<div id="m_403900111212837041hs_cos_wrapper_module_16933597020671" style="color:inherit;font-size:inherit;line-height:inherit"><table width="100%" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse">

+

  <tbody>

+

    <tr>

+

      <td align="center" valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;color:#073763;word-break:break-word;text-align:center;padding:10px 20px;font-size:0px">

+

        <img alt="Congratulations Banner_Twitter Image_1200x675(1)" src="https://hs-7592558.f.hubspotemail.net/hub/7592558/hubfs/Congratulations%20Banner_Twitter%20Image_1200x675(1).png?width=1120&amp;upscale=true&amp;name=Congratulations%20Banner_Twitter%20Image_1200x675(1).png" style="outline:none;text-decoration:none;max-width:100%;font-size:16px; display:inline;height:auto;" width="560" align="middle" loading="lazy">

+

      </td>

+

    </tr>

+

  </tbody>

+

</table></div>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16909010070102" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16909010070102_" style="color:inherit;font-size:inherit;line-height:inherit"><p style="line-height:125%"><span style="color:#000000">The wait is finally over for the popular USDC stablecoin on Polkadot.&nbsp; Circle began the process of launching native USDC in the Polkadot ecosystem on September 19th, eliminating the need to use bridged versions of the stablecoin.&nbsp;</span></p>

+

<p style="line-height:125%"><span style="color:#000000"><br><span style="font-size:15px;color:#000000">While native USDC is now available to all parachains and dapps in the ecosystem, support for the stablecoin will roll out gradually across parachains, dapps, and exchanges. </span><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kC3qn9gW7lCdLW6lZ3pVW2FCPmC1CczzGW9kwWzW2s-WpXW20XWv-6cC7zvW6fv-Dp744QWHW5nmDzz2tMJ77W4xnY8Z7nmJxnW34x3nN5M3b9SW5KxRSQ48vgggN9dDk7sHdmfmW8-_8Y41Z-kDMN6TbR5lppmq1W5KgY6B1FtCkmVlnGRV58p3xBW8Wjxq05C7BKnW6KKZ_Y8WPV7pV6XkF97W2MFpW8j6XCZ1W3F-FW6Y66452xyMfTW591lfZ8ZdB4nN5GYkqc9g2VFW7ZxH8z77YnhwVYrVFn1f6blGW4v_dP64HvkmMW4TlHhk9hcsqQf1kB1nd04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">Centrifuge <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kC3qn9gW7lCdLW6lZ3pVW2FCPmC1CczzGW9kwWzW2s-WpXW20XWv-6cC7zvW6fv-Dp744QWHW5nmDzz2tMJ77W4xnY8Z7nmJxnW34x3nN5M3b9SW5KxRSQ48vgggN9dDk7sHdmfmW8-_8Y41Z-kDMN6TbR5lppmq1W5KgY6B1FtCkmVlnGRV58p3xBW8Wjxq05C7BKnW6KKZ_Y8WPV7pV6XkF97W2MFpW8j6XCZ1W3F-FW6Y66452xyMfTW591lfZ8ZdB4nN5GYkqc9g2VFW7ZxH8z77YnhwVYrVFn1f6blGW4v_dP64HvkmMW4TlHhk9hcsqQf1kB1nd04</span></a><span style="font-size:15px;color:#000000">, </span><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3kHW6Nq1Nk7DQbSFVks6-D10xrHTW8LmS9t1d6T3PW6_d4r03n5vcgW2bxP052NjN7nW91CKy57nZBmJW3C0V7T3G941wW4K8QQc5333FnW2bNNZm4wSJzRW1-t3lX56bHS8W4QSmWV8_kzyVV4ksPX6FCgt8W6rk9Jb6n66k9W5CNW7N47QPJ8W823Pht8FW2hpW3XWxl68vv2PsN4D2mFCVTnGgW1jgxrW7K-bJVW5fXGW97ZRQflW80Ww3-70F2SxN7ZLJGM2XJ6PW56Mj3W2Xz5xvN3FQ1G-lPdVQW1R-QJc4YqRLWW3BM5y57bYGnZW2vmlDg4Z88jTW20CqqX4cSmMYW7DTj2j99JNSGN7hbM3T1n89SW8WVj2N7-mj4Kf95zVsn04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">HydraDX <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3kHW6Nq1Nk7DQbSFVks6-D10xrHTW8LmS9t1d6T3PW6_d4r03n5vcgW2bxP052NjN7nW91CKy57nZBmJW3C0V7T3G941wW4K8QQc5333FnW2bNNZm4wSJzRW1-t3lX56bHS8W4QSmWV8_kzyVV4ksPX6FCgt8W6rk9Jb6n66k9W5CNW7N47QPJ8W823Pht8FW2hpW3XWxl68vv2PsN4D2mFCVTnGgW1jgxrW7K-bJVW5fXGW97ZRQflW80Ww3-70F2SxN7ZLJGM2XJ6PW56Mj3W2Xz5xvN3FQ1G-lPdVQW1R-QJc4YqRLWW3BM5y57bYGnZW2vmlDg4Z88jTW20CqqX4cSmMYW7DTj2j99JNSGN7hbM3T1n89SW8WVj2N7-mj4Kf95zVsn04</span></a><span style="font-size:15px;color:#000000">, </span><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8jq5nXHsW50kH_H6lZ3p6W8JtcPK2KdhVfW3St2xS41RVNnW78Yx4-5HTqytW7F6klm7F59YVW41Wbh348zsmXW4jqVHp3D_tGSW68Vrtx1qWyXPW7vNz1097Y-gwW42-30Y26VnsxVjRqFD49sXclW1v7tnX9jR-8TN7Bblc6tBbqgVp-GqK3bRSmLW2Fcmw5168zGHW22DjCG1bcffpW1n_N248X8S5VW1Hdgg12yM-0gW5RXyQ71klF3cW37KC7g3Hpt02W7m5bBK8Trjy2W2_LT403nGW8zW47ZcHJ81Tl6PW1XM8hT6P90TxW5QCbn47x8bHqW2HtXX52r_6jCW7DdMmP8Xt5QkW1L0bH84QbD-pW8_HHyJ4TCFydV5Jh8p78DvZzW7t4SV795YzKDW92_Hr23QtgcZW1MWfmr6sfzWFdrhgYH04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">Moonbeam <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8jq5nXHsW50kH_H6lZ3p6W8JtcPK2KdhVfW3St2xS41RVNnW78Yx4-5HTqytW7F6klm7F59YVW41Wbh348zsmXW4jqVHp3D_tGSW68Vrtx1qWyXPW7vNz1097Y-gwW42-30Y26VnsxVjRqFD49sXclW1v7tnX9jR-8TN7Bblc6tBbqgVp-GqK3bRSmLW2Fcmw5168zGHW22DjCG1bcffpW1n_N248X8S5VW1Hdgg12yM-0gW5RXyQ71klF3cW37KC7g3Hpt02W7m5bBK8Trjy2W2_LT403nGW8zW47ZcHJ81Tl6PW1XM8hT6P90TxW5QCbn47x8bHqW2HtXX52r_6jCW7DdMmP8Xt5QkW1L0bH84QbD-pW8_HHyJ4TCFydV5Jh8p78DvZzW7t4SV795YzKDW92_Hr23QtgcZW1MWfmr6sfzWFdrhgYH04</span></a><span style="font-size:15px;color:#000000">, and </span><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3n7W37_0gp4SDdcSW1Cz5W05PJCK1W6gj1KF4KqM0_W3lyC6t1q3BHtW4BW1nL3KkFqjW3047s619nkGmW8sNlyB4-ys0-W7tbgFx5R_m8qW2MRp9s5PHCJtW1ykTL13m1qRnW1cZQss2X-8XSN1DMN0M-MdPKW6pbZd52gCpQrW4Hnbpn1zB74hW7ZT1Zm8WYvVTW6ZTMqw8YpY1QW15s02Z6JsLmWVgV3Hl8ZsQ9RW2bY65k6rFjxxW8pxWFc4F5vJlW5rw2cy6Xjj2NVN9XSX6zkyjlW2kYSmc8wgzhqW3kL5VQ3pQD5SV52JS48c5K0bW28BwGm5H0DTbN5jKk_2_3_DWW2xBslw4S0tnPW6msytR1vJMmGW7rMBZ08ZRD7Bf58bd-804" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">Interlay <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3n7W37_0gp4SDdcSW1Cz5W05PJCK1W6gj1KF4KqM0_W3lyC6t1q3BHtW4BW1nL3KkFqjW3047s619nkGmW8sNlyB4-ys0-W7tbgFx5R_m8qW2MRp9s5PHCJtW1ykTL13m1qRnW1cZQss2X-8XSN1DMN0M-MdPKW6pbZd52gCpQrW4Hnbpn1zB74hW7ZT1Zm8WYvVTW6ZTMqw8YpY1QW15s02Z6JsLmWVgV3Hl8ZsQ9RW2bY65k6rFjxxW8pxWFc4F5vJlW5rw2cy6Xjj2NVN9XSX6zkyjlW2kYSmc8wgzhqW3kL5VQ3pQD5SV52JS48c5K0bW28BwGm5H0DTbN5jKk_2_3_DWW2xBslw4S0tnPW6msytR1vJMmGW7rMBZ08ZRD7Bf58bd-804</span></a><span style="font-size:15px;color:#000000"> are among the parachains planning support for Polkadot-native USDC.</span></span></p></div></div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16934960686301" style="color:inherit;font-size:inherit;line-height:inherit">

+


+

  <table align="center" border="0" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:separate!important">

+

    <tbody><tr>

+


+


+

      <td align="center" valign="middle" bgcolor="#e6007a" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;border-radius:25px;background-color:#e6007a">

+


+

        <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8k05nXHsW69t95C6lZ3nTW7X1JnV1Q5khLW20vFKQ12LH24W4lXMjB8WXHgTVQrM3874_0l8W2hqCbj5mQJkrW4p6bwc6PJffLW58hTTN8jnx-KV3rXyz2jnTJ-W95zXZz2NVpJVVZs1Vm37YvsTW4fNB4k5Stl6kVQWh9b25q3FTW70B2zK27kJ5gVVBG0H7blVrBW1kVN9f89HTkJW8Kczrs4nDwTwW3RMHJy1m9Mt5W2fQ-R487BZNjW6ZlqdZ1rqVnnW6TGlDq58_mbcW3r1S6k7lrKHtW3WHslF2BVr50W5PcX3F8m-12cW7xKSBG9c0lKrW5PHlvr1CDM4SW3fWKW32q0q31W7NvK583QfwSBW6Lb7H32xwx4_W1ZPsL06ZVQm-N7Zx-qj8m7wlW8ltsNC8dQb8MN8Z1tgH6Z66dN1ZmtCSwdw3zVXCFb_79594sW8gXz8h7hkZdgW5YgnX77VnRqNf1cxbLT04" style="color:#00a4bd;font-size:15px;font-family:Lato,Tahoma,sans-serif;Margin:0;text-transform:none;text-decoration:none;padding:12px 18px;display:block" target="_blank" rel="noreferrer">

+

          <strong style="color:#ffffff;font-weight:bold;text-decoration:none;font-style:normal">Read more</strong>

+

        </a>

+

      </td>

+

    </tr>

+

  </tbody></table>

+

</div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px 0px"><div id="m_403900111212837041hs_cos_wrapper_module_16933598493742" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16933598493742_" style="color:inherit;font-size:inherit;line-height:inherit"><h2 style="margin:0;line-height:125%;font-size:22px;text-align:left" align="left"><span style="color:#000000">Async Backing: Coming Soon! 👀<br></span></h2></div></div></td></tr></tbody></table>

+

<div id="m_403900111212837041hs_cos_wrapper_module_16933603637184" style="color:inherit;font-size:inherit;line-height:inherit"><table width="100%" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse">

+

  <tbody>

+

    <tr>

+

      <td align="center" valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;color:#073763;word-break:break-word;text-align:center;padding:10px 20px;font-size:0px">

+

        <img alt="Asynchronous backing(1)" src="https://hs-7592558.f.hubspotemail.net/hub/7592558/hubfs/Asynchronous%20backing(1).png?width=1120&amp;upscale=true&amp;name=Asynchronous%20backing(1).png" style="outline:none;text-decoration:none;max-width:100%;font-size:16px; display:inline;height:auto;" width="560" align="middle" loading="lazy">

+

      </td>

+

    </tr>

+

  </tbody>

+

</table></div>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16959967694431" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16959967694431_" style="color:inherit;font-size:inherit;line-height:inherit"><p style="line-height:125%"><span style="color:#000000">Asynchronous backing, an eagerly-awaited upgrade bringing a theoretical 8x increase to Polkadot’s scalability, is nearing the finish line, announced Parity Engineering Lead Sophia Gold in a <span style="color:#e6007a"><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3pJW8K6LML7wynf6W5d3hzl5F8zlqW7NFXfk5BJw2jW7p5XZt73XYmYW6wD4br8KBgr8W67DZm16YRVlxW7HW6gd6Qp0x1N2lXyxmv9dBsW6jhSYb8h6q5_V4C7Zs7tyz2WW5Q3d0k1C9KGYW4N707R5fy41BW7khffw8V7T9dN6Bg-FhYtd3GW3B8NJ-87DqWVW6CQXyj5X2f5YW7xYYW169Cll5W664gnV6VfvdmW1KTC3q6mKThFW7684_d6yBHn1W89KG8Z4T2222N3WxNBV7-QjgW5T6HbB2ZM_9SW6zljx738K_4tW7kFWts1LmLwPW5D38Np7HtNXYf2wGp9T04" style="color:#e6007a" target="_blank" rel="noreferrer" class="tmail-tooltip">recent talk <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3pJW8K6LML7wynf6W5d3hzl5F8zlqW7NFXfk5BJw2jW7p5XZt73XYmYW6wD4br8KBgr8W67DZm16YRVlxW7HW6gd6Qp0x1N2lXyxmv9dBsW6jhSYb8h6q5_V4C7Zs7tyz2WW5Q3d0k1C9KGYW4N707R5fy41BW7khffw8V7T9dN6Bg-FhYtd3GW3B8NJ-87DqWVW6CQXyj5X2f5YW7xYYW169Cll5W664gnV6VfvdmW1KTC3q6mKThFW7684_d6yBHn1W89KG8Z4T2222N3WxNBV7-QjgW5T6HbB2ZM_9SW6zljx738K_4tW7kFWts1LmLwPW5D38Np7HtNXYf2wGp9T04</span></a></span> at Sub0.&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">The upgrade, due for release soon on the Polkadot testnet Rococo, will also enable a number of groundbreaking future upgrades proposed for Polkadot, including Agile Coretime (see the newsletter’s previous edition for an explanation).&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">Gold described async backing as “the most significant evolution of parachain consensus since we launched parachains almost two years ago.” In time, async backing is expected to enable the ecosystem to support upwards of 1,000+ parachains and 1M+ transactions per second, so Polkadot will be ready for a future of Web3 mass adoption.</span></p></div></div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16959968463442" style="color:inherit;font-size:inherit;line-height:inherit">

+


+

  <table align="center" border="0" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:separate!important">

+

    <tbody><tr>

+


+


+

      <td align="center" valign="middle" bgcolor="#e6007a" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;border-radius:25px;background-color:#e6007a">

+


+

        <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8jK5nXHsW5BWr2F6lZ3pRW1J5_jT2mFhQRW7CYFQL5t95dwW6k_0YY3NlPG_W4LSJ1k4WSNX9W5dY0x95KwwS9Vyz4pX8_129GW1f5f3n3WLdJgW69Tzb25pw4qcVDN1J89f_n-SW7-8nV38ybjbFW4fBnGT55WV2rW9jQ8f22GzwKRW4qdJGF1sHd_8W4r-1Dz4_sqB4W8jrstv2rHZd0W8NR-kg8SWdtPW2g4cjK3nc-TPMcgqlXQmlmxW96mQG66-b4dRW98RGBg43gXH0W5h1CKQ5kQV8WW6FS-FX2Vbs2pW4wqnG34qv9nCW3HxGrL4mzhtmW3RT3DM7TC049W1HbyQM8_MX5cW74RPZ_6YC8FYW8zb5L35cX7ZQW4YwGDT9fCqGcW3mJRG-4t2jzBW4CVnpK8HFdqDW6_vX1X2jW_H2W5d2mcK6yxZDkW59z6Fk54RNWrf6mjr5604" style="color:#00a4bd;font-size:15px;font-family:Lato,Tahoma,sans-serif;Margin:0;text-transform:none;text-decoration:none;padding:12px 18px;display:block" target="_blank" rel="noreferrer">

+

          <strong style="color:#ffffff;font-weight:bold;text-decoration:none;font-style:normal">Read more</strong>

+

        </a>

+

      </td>

+

    </tr>

+

  </tbody></table>

+

</div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px 0px"><div id="m_403900111212837041hs_cos_wrapper_module_16959968822463" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16959968822463_" style="color:inherit;font-size:inherit;line-height:inherit"><h2 style="margin:0;line-height:125%;font-size:22px;text-align:left" align="left"><span style="color:#000000">Lingo Unchained: Blockspace 🔲<br></span></h2></div></div></td></tr></tbody></table>

+

<div id="m_403900111212837041hs_cos_wrapper_module_16959969178084" style="color:inherit;font-size:inherit;line-height:inherit"><table width="100%" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse">

+

  <tbody>

+

    <tr>

+

      <td align="center" valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;color:#073763;word-break:break-word;text-align:center;padding:10px 20px;font-size:0px">

+

        <img alt="Blockspace themed" src="https://hs-7592558.f.hubspotemail.net/hub/7592558/hubfs/Blockspace%20themed.png?width=1120&amp;upscale=true&amp;name=Blockspace%20themed.png" style="outline:none;text-decoration:none;max-width:100%;font-size:16px; display:inline;height:auto;" width="560" align="middle" loading="lazy">

+

      </td>

+

    </tr>

+

  </tbody>

+

</table></div>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16959969331755" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16959969331755_" style="color:inherit;font-size:inherit;line-height:inherit"><p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">Specialized lingo and technical jargon are no stranger to the Web3 space, making it difficult for many people to understand the technology. The term blockspace is a new one for many, but since Polkadot is referred to as a ‘<span style="color:#e6007a"><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3njW2YRXG86V4QktW4jgdjr3x0WKGW1NKJjC56BN77W1GhYnZ2f-z7LW8BLpYw92dtQJW64Yjn815c1NKW7s-HDV53tPJvV3RG7d7xrhrtW4gCYk67xN_m8VZnwkq64vMBDW6t6ZQG13MrKbW1sbzYD3kHLgkN5MT1bpKP_81W2ZdpzD5kVMJnW8RNpD65G18W0W4zgX5m6G_gDdN80FmkHmsGXBW3wNZFL2QKhHTW46bgqt5d1KC1W3t5qNX7hb4NGW1MGKD27s3HQBN1Hql0_7wHDHW1QKT1F709SZQN2n_4cy_RTb5W3nm97L8tHqdJW5Kv1tw16KQY1f5M8tVY04" style="color:#e6007a" target="_blank" rel="noreferrer" class="tmail-tooltip">blockspace ecosystem <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3njW2YRXG86V4QktW4jgdjr3x0WKGW1NKJjC56BN77W1GhYnZ2f-z7LW8BLpYw92dtQJW64Yjn815c1NKW7s-HDV53tPJvV3RG7d7xrhrtW4gCYk67xN_m8VZnwkq64vMBDW6t6ZQG13MrKbW1sbzYD3kHLgkN5MT1bpKP_81W2ZdpzD5kVMJnW8RNpD65G18W0W4zgX5m6G_gDdN80FmkHmsGXBW3wNZFL2QKhHTW46bgqt5d1KC1W3t5qNX7hb4NGW1MGKD27s3HQBN1Hql0_7wHDHW1QKT1F709SZQN2n_4cy_RTb5W3nm97L8tHqdJW5Kv1tw16KQY1f5M8tVY04</span></a><span style="color:#000000">’</span></span>, it’s key to understanding what sets Polkadot apart. So, let’s break it down:</span></p>

+

<p style="line-height:125%"><span style="color:#000000">&nbsp;</span></p>

+

<p style="line-height:125%"><span style="color:#000000">Blockspace is the main product blockchains offer the world. Like the name suggests, you can think of it as the virtual ‘space’ within a block, where all the interesting stuff happens on a blockchain.&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">More precisely, it’s a blockchain’s ability to run apps and record, update, process, and verify data in a decentralized way from around the world. Blockspace is the raw material that developers can use to build all sorts of interesting things, from DeFi apps to advanced enterprise infrastructure.&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">But not all blockspace is created equal. Similar to how wine from different regions and years can vary in quality and flavor, blockspace from different sources can vary greatly in terms of security, availability, flexibility, cost effectiveness, and other characteristics. When you’re dealing with valuable financial assets, personal data, or critical programs, these things are crucially important.&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">So, those choosing a Web3 platform should understand the capabilities of this resource they’re acquiring and how it will affect their product and their users. Blockspace with poor availability, for example, leads to congestion and high fees for end-users, while blockspace with poor security guarantees makes the network vulnerable to attack.&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">Blockspace from different blockchains can also offer specialized capabilities to serve different use cases. Since no one blockchain is perfect for every use case, Polkadot is designed for a multichain world where different blockchains offer fit-for-purpose blockspace for different applications and industries.&nbsp;</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">Polkadot is called a blockspace ecosystem because it combines blockspace from multiple specialized parachains into a single, securely connected ecosystem. It’s a unified blockspace marketplace enabling innovators to mix and match blockspace to meet the needs of their use case, available in the right quantity, right when they need it, and for the right price.</span></p>

+

<p style="line-height:125%">&nbsp;</p>

+

<p style="line-height:125%"><span style="color:#000000">The flexibility Polkadot offers with healthy, high-quality, interoperable blockspace unlocks boundless innovation in Web3, going beyond the trade-offs and limitations of previous networks. With several proposals underway for improving how the network allocates blockspace (see the Agile Coretime story in our previous edition), Polkadot continues to provide the most viable technical foundation for realizing the real Web3 vision.</span></p>

+

<p style="line-height:125%"><span style="color:#000000"><br><span style="font-size:15px">Read more about </span><span style="color:#e6007a"><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8k05nXHsW69t95C6lZ3n8W494mLL4_4KDdW9fqNWC8_fKpFW3qV-K78ZcfQQN55XcpdRBbgWW7XK-h93-T90gW5BmKVc3rD6KmW1ZkL3y1vQ-WXW84T8Y96nl4YLW8thcxy7Z4gFHW46ZsfP8StWjYW30NrGq3RDCRtW3s8nyN5bzDfCN1gmH75GbHZ2W2CJS5c957hppW46trGQ5XrnmhW42DJ2s4BwkG9VB9s0C7HYZq6W4k58PJ6N_CXPW5j3GqW8VFt-mVCk8sK4-V236W63dTQz4SFLxZW6z8mVm8mGmhRN1qNSs8DCt34W13619d1MHpY6W2HTS804c15lRW2YbZ5W3mQ-ZzW5wNn9K6vpMt2W63tnf95FKfNGW4Y_mJJ6v4n-0VxhMmK26WpJJW8GfXMT7R_B88W8yyb2M4HKt5CW1kLjm842p2bMW8-Xmdm5zg_0sN14hJ88fSbrhW209Xj41gk98jf1VR3zj04" style="font-size:15px;color:#e6007a" target="_blank" rel="noreferrer" class="tmail-tooltip">Polkadot’s blockspace <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8k05nXHsW69t95C6lZ3n8W494mLL4_4KDdW9fqNWC8_fKpFW3qV-K78ZcfQQN55XcpdRBbgWW7XK-h93-T90gW5BmKVc3rD6KmW1ZkL3y1vQ-WXW84T8Y96nl4YLW8thcxy7Z4gFHW46ZsfP8StWjYW30NrGq3RDCRtW3s8nyN5bzDfCN1gmH75GbHZ2W2CJS5c957hppW46trGQ5XrnmhW42DJ2s4BwkG9VB9s0C7HYZq6W4k58PJ6N_CXPW5j3GqW8VFt-mVCk8sK4-V236W63dTQz4SFLxZW6z8mVm8mGmhRN1qNSs8DCt34W13619d1MHpY6W2HTS804c15lRW2YbZ5W3mQ-ZzW5wNn9K6vpMt2W63tnf95FKfNGW4Y_mJJ6v4n-0VxhMmK26WpJJW8GfXMT7R_B88W8yyb2M4HKt5CW1kLjm842p2bMW8-Xmdm5zg_0sN14hJ88fSbrhW209Xj41gk98jf1VR3zj04</span></a></span><span style="font-size:15px"><span style="color:#e6007a"> <span style="color:#000000">and</span></span> </span><span style="color:#e6007a"><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3l3VPwj2r6X_CrYW1bfM0Y7-v65zN2YpXstVVGDsW30dZ638YpzHQW7TBfbF31y7j7W5ZNDBT41QHL0W9kRZqZ93Q4FyW46PTmr2g62XsW3j4GVp3Cz6T7W408fcv3d3KG-VN0RR_2Sl3cvW2tpx7Q7nM-4XW6pwXBk1r4zgJVywljG3Sbck6W6VS9Xh6ghJlHW7p5wcL7prHrmW3MtcQW6cQScmW1t6rKZ5bBclnW5CbVxp25HqNBV64YKH7Lr0lSW91CPR15z-WK8W6q_JS11F_pR9W8GbVky3XDkmFW5WKYFl7Xm0n3W7HjQQv2WzQDkMvxrFd4MBLBW1xkntm2F-k4DW15C6By15HLTwVzH-xz2LJmxYW2W_CX829csBsf9jQHvd04" style="font-size:15px;color:#e6007a" target="_blank" rel="noreferrer" class="tmail-tooltip">how it empowers developers <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3l3VPwj2r6X_CrYW1bfM0Y7-v65zN2YpXstVVGDsW30dZ638YpzHQW7TBfbF31y7j7W5ZNDBT41QHL0W9kRZqZ93Q4FyW46PTmr2g62XsW3j4GVp3Cz6T7W408fcv3d3KG-VN0RR_2Sl3cvW2tpx7Q7nM-4XW6pwXBk1r4zgJVywljG3Sbck6W6VS9Xh6ghJlHW7p5wcL7prHrmW3MtcQW6cQScmW1t6rKZ5bBclnW5CbVxp25HqNBV64YKH7Lr0lSW91CPR15z-WK8W6q_JS11F_pR9W8GbVky3XDkmFW5WKYFl7Xm0n3W7HjQQv2WzQDkMvxrFd4MBLBW1xkntm2F-k4DW15C6By15HLTwVzH-xz2LJmxYW2W_CX829csBsf9jQHvd04</span></a></span><span style="font-size:15px">. </span></span></p></div></div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px 0px"><div id="m_403900111212837041hs_cos_wrapper_module_16933609549185" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16933609549185_" style="color:inherit;font-size:inherit;line-height:inherit"><h2 style="margin:0;line-height:196%;font-size:22px;text-align:left" align="left"><span style="color:#000000">Ecosystem News Deep-Dive&nbsp;</span><span style="color:#000000">🤿</span></h2></div></div></td></tr></tbody></table>

+

<div id="m_403900111212837041hs_cos_wrapper_module_16933609685786" style="color:inherit;font-size:inherit;line-height:inherit"><table width="100%" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse">

+

  <tbody>

+

    <tr>

+

      <td align="center" valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;color:#073763;word-break:break-word;text-align:center;padding:10px 20px;font-size:0px">

+

        <img alt="01-a" src="https://hs-7592558.f.hubspotemail.net/hub/7592558/hubfs/01-a.png?width=1120&amp;upscale=true&amp;name=01-a.png" style="outline:none;text-decoration:none;max-width:100%;font-size:16px; display:inline;height:auto;" width="560" align="middle" loading="lazy">

+

      </td>

+

    </tr>

+

  </tbody>

+

</table></div>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16644554223362" style="color:inherit;font-size:inherit;line-height:inherit"><div id="m_403900111212837041hs_cos_wrapper_module_16644554223362_" style="color:inherit;font-size:inherit;line-height:inherit"><ul style="line-height:175%">

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000"><strong>Sub0 talks are now online: </strong><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8jq5nXHsW50kH_H6lZ3kyW2X1TdM42qlnYVm_tDg8dQJhPW6jlXHd2FCZrqVG8jj35cmX7tW8kYmn192nj3XN1RybwYhGdjJW30qxHn4QywDpVmcxMv848JwkW1gxYgs3ZksbWW8x7cgY4yYr0_W33R1Qp5hSZSzW4JMZZT3rZQs2W8bT92r3GxZ5VW6LhSQF4ysZFjW761j982-ggSfW5Pws3H4Z6XTDW5Xh4nb1-R3pXW3g--bC5Gxj-xW6l-WNF63HT8cW7K6tY25cB6MxN4Gr0PnM-g8gMN2_ZGMfT7JW5yT0Yw39CzkmW1X_pKY6zvmBcW8jtW-V1Lmq5DW2Y8nJC89XppkW6LZRyc6-9LbtW7hmd-N7LlT2GW2ksmV-4_SvNlW5JyR5j6nzbNBN9h5MFJ-Xg5zW8RHD1H6kQfbDf2_WCx804" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">watch on YouTube <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8jq5nXHsW50kH_H6lZ3kyW2X1TdM42qlnYVm_tDg8dQJhPW6jlXHd2FCZrqVG8jj35cmX7tW8kYmn192nj3XN1RybwYhGdjJW30qxHn4QywDpVmcxMv848JwkW1gxYgs3ZksbWW8x7cgY4yYr0_W33R1Qp5hSZSzW4JMZZT3rZQs2W8bT92r3GxZ5VW6LhSQF4ysZFjW761j982-ggSfW5Pws3H4Z6XTDW5Xh4nb1-R3pXW3g--bC5Gxj-xW6l-WNF63HT8cW7K6tY25cB6MxN4Gr0PnM-g8gMN2_ZGMfT7JW5yT0Yw39CzkmW1X_pKY6zvmBcW8jtW-V1Lmq5DW2Y8nJC89XppkW6LZRyc6-9LbtW7hmd-N7LlT2GW2ksmV-4_SvNlW5JyR5j6nzbNBN9h5MFJ-Xg5zW8RHD1H6kQfbDf2_WCx804</span></a> for all the latest from the Polkadot developer community <br></span></span></li>

+

&nbsp;

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000;font-weight:normal"><strong>Snowbridge Ethereum Bridge expected to launch on Kusama this year,</strong> according to the team’s <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3pqW2vCHDh3phSr2W7L4x9S8pCJD7W6KCkKd3GTDd5W1_krrt3HC30CW1q8dXZ3S4nvXW8qwWJv3sWB4rW3vLwfN2MMql1W4nysVM7WD-K5W5J7_vW6-PpdyW1S1Nnr5XcbqqW8tblvs7c4H37W62c4Rv2Nd7gPW8dlxWC1n8wftW5r38qC1jMFcGW9fXZ-t2m15nmW6f-qDY3b3q43W7RJZCF83Tz7GW6WsnkY4Xhp_BW62T4bt3PfV_0VrHd4t5T_SMLW4qWLWL5j4qg0F2BR3948VdXN27tz4w9d0C7W697v2y8PzM5-W6Z_t6C9gCZGcW1mlnXX5KLZLSf6FkWyP04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">Sub0 talk <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3pqW2vCHDh3phSr2W7L4x9S8pCJD7W6KCkKd3GTDd5W1_krrt3HC30CW1q8dXZ3S4nvXW8qwWJv3sWB4rW3vLwfN2MMql1W4nysVM7WD-K5W5J7_vW6-PpdyW1S1Nnr5XcbqqW8tblvs7c4H37W62c4Rv2Nd7gPW8dlxWC1n8wftW5r38qC1jMFcGW9fXZ-t2m15nmW6f-qDY3b3q43W7RJZCF83Tz7GW6WsnkY4Xhp_BW62T4bt3PfV_0VrHd4t5T_SMLW4qWLWL5j4qg0F2BR3948VdXN27tz4w9d0C7W697v2y8PzM5-W6Z_t6C9gCZGcW1mlnXX5KLZLSf6FkWyP04</span></a><br><br></span></span></li>

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000;font-weight:normal"><strong>Zondax announced a new Polkadot Ledger app</strong> that will support any parachain in the network, rather than needing a separate app for each parachain, <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3n5W5Y-8jB5BwW6ZW7hJXNq3vX_kZW44ddDz7qzLC4W3ywpGw1d92Z1VSww4F5tJ8sbW7ZnF964RPYXjW79HRw142cpsBW8CXHPq79zvTtW3_pYpH1Npj_qW4Fg1nN4pj4BkW1YDXrv5S8J0nW2m_qhq6BTqf9W50drhP4ySqVbW8-kGRQ8jbXC1W4ZsQD26jbJ08N24KdMlCrx_7W1RYqmt14TnVFVJ8SQF5G48GYW2ctwYV5DQSYcVlnFsZ7Vh_WhVrqN504VNp4LW2gBHRF7sbNRDW7WVw8_1j3cf7N53GvX_dh6TRW8pyP6R6_VWgPW3Wrpc38plkmyf4ZMLhP04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">at Sub0 <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3n5W5Y-8jB5BwW6ZW7hJXNq3vX_kZW44ddDz7qzLC4W3ywpGw1d92Z1VSww4F5tJ8sbW7ZnF964RPYXjW79HRw142cpsBW8CXHPq79zvTtW3_pYpH1Npj_qW4Fg1nN4pj4BkW1YDXrv5S8J0nW2m_qhq6BTqf9W50drhP4ySqVbW8-kGRQ8jbXC1W4ZsQD26jbJ08N24KdMlCrx_7W1RYqmt14TnVFVJ8SQF5G48GYW2ctwYV5DQSYcVlnFsZ7Vh_WhVrqN504VNp4LW2gBHRF7sbNRDW7WVw8_1j3cf7N53GvX_dh6TRW8pyP6R6_VWgPW3Wrpc38plkmyf4ZMLhP04</span></a><br><br></span></span></li>

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000;font-weight:normal"><strong>Google Cloud is adding Polkadot to its </strong><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8k05nXHsW69t95C6lZ3n0W688kmR1kdRbxV8b_lp62gB0TW72cDk34mnhrXW379z8t7q6bXWN5fMvdmsfgSJVbVXwm3S5dvlW82BHYZ1_p-55W97TSLQ8QWlbJW5nG4fR4_BTybN7nP2kv8hR0TW4JLym16zh_KDW86pN5k8-C211W1NYL8f53bPDRW4SfC5T3bhH-QW2-r1Df4_T6cxW8Q6p_G7vM0QCW3ZwSxP7vT0-3W236DfT66ppwbN8K6NcSg88-XW1t-xPm6f06yGW1xPX1b1V_6DVW6TQD7x22pzhRVFg9mG1h_nSVW6qb4855MftM5W4-w7yd3fbPz5VnS-0H8LrL_3W5gM4bW1cpknMW6kW0cZ4KDCjBW1JVDB290-_mLW6Sz4FX77v0-pW2yRYLK31-PwFW2HDGs22MHcY6VV73VT5WLlM3W39nkDD5gWm4ZW6Fmn2J2M5wMvW2-gFwj4MyDQDf1wRFMs04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer"><strong>BigQuery</strong></a><strong> program</strong> for public datasets. BigQuery allows users to find data faster than through querying blockchains<br><br></span></span></li>

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000;font-weight:normal"><strong>Zodia Custody announced institutional custody and staking services on Polkadot.</strong> <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3lsW6PGh3k30mVXNW7vS9-R8VypqxMvzHYbV1cZNW5-7NBM3L5CN2W3svtTQ5mhryLN76sp5cy3Sl2VlM_kM6TVtw_W93lKvh530dYKW4JZz4Q87ttsbW8KWC0-1R5RBVW735CFD2b-46MW9fsyjw7DP60ZW5L1MrX8sLj1fW50_2lM5TV8KKW1JSK0f8SCs2XW2_y7d63PjLj7W739B-q1SlwK-VjctXG7PFf_4V2LJtj9hJKvrW2dDhSp4gpXT2W9682pS36f-x3W9gcXCx6pVqQ2W4166Wz6qB42MW4Bw0tj2sn_qfW6ZVkVB2KL0PtW5dYR_V9jWx6_W4jH95d8WKPxSW837qQF18nQXvV6_7KT4JkJ76W5Gxm9x8c1LYKf516cQF04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">Zodia <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8lv3qn9gW95jsWP6lZ3lsW6PGh3k30mVXNW7vS9-R8VypqxMvzHYbV1cZNW5-7NBM3L5CN2W3svtTQ5mhryLN76sp5cy3Sl2VlM_kM6TVtw_W93lKvh530dYKW4JZz4Q87ttsbW8KWC0-1R5RBVW735CFD2b-46MW9fsyjw7DP60ZW5L1MrX8sLj1fW50_2lM5TV8KKW1JSK0f8SCs2XW2_y7d63PjLj7W739B-q1SlwK-VjctXG7PFf_4V2LJtj9hJKvrW2dDhSp4gpXT2W9682pS36f-x3W9gcXCx6pVqQ2W4166Wz6qB42MW4Bw0tj2sn_qfW6ZVkVB2KL0PtW5dYR_V9jWx6_W4jH95d8WKPxSW837qQF18nQXvV6_7KT4JkJ76W5Gxm9x8c1LYKf516cQF04</span></a>, a leading digital asset custodian whose shareholders include Standard Chartered, SBI Holdings and Northern Trust, will also support institutional access to Polkadot through joint R&amp;D initiatives <br><br></span></span></li>

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000;font-weight:normal"><strong>EnergyWeb secured its Polkadot parachain slot</strong> and unveiled a partnership with <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8k05nXHsW69t95C6lZ3nxW1rqPsQ822nSJW1V2Dg-67CzhkW5PyFS03WCvNRW2Jk8Kq3gy6YVW6nfRb51K1p4lW3rV6FN1zLm9CW4zVTJv991jZLV97KZ0642KNjW84St5V2xN65YVbgrfx4QHbczN2HNx6GZ-wwPW7SLS2m6dcrkZW8jDCh08lvPWJW1cvkHC6sjFHfN62B4k7h1vtcW6n3BXg7kVXxRW77_SHR3Ss76rW4ymxBf4VLWynN8yJ0jQHSgBWW8sMKpN8ssN4pW14c1QX26DVj6W6f2d8-1b-BJzW1QJk3h8cY81yW8VKSN38Rv9tyW8S4jRy1XGSB4N4VFcw0V1Z7VW8Ctgg35GK222W1L9k2X5R61FmW44-j4P6ZjkPxN7sCvHB4j7-WW7WqxpV4V0LSlW5XsQLy6YVhh3TPfnN8tN56RW4PKTvd8syJh_W2CsHHM6V3JsMN8Yt_QHDcbCbf9809pT04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">CarbonEnfo <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8k05nXHsW69t95C6lZ3nxW1rqPsQ822nSJW1V2Dg-67CzhkW5PyFS03WCvNRW2Jk8Kq3gy6YVW6nfRb51K1p4lW3rV6FN1zLm9CW4zVTJv991jZLV97KZ0642KNjW84St5V2xN65YVbgrfx4QHbczN2HNx6GZ-wwPW7SLS2m6dcrkZW8jDCh08lvPWJW1cvkHC6sjFHfN62B4k7h1vtcW6n3BXg7kVXxRW77_SHR3Ss76rW4ymxBf4VLWynN8yJ0jQHSgBWW8sMKpN8ssN4pW14c1QX26DVj6W6f2d8-1b-BJzW1QJk3h8cY81yW8VKSN38Rv9tyW8S4jRy1XGSB4N4VFcw0V1Z7VW8Ctgg35GK222W1L9k2X5R61FmW44-j4P6ZjkPxN7sCvHB4j7-WW7WqxpV4V0LSlW5XsQLy6YVhh3TPfnN8tN56RW4PKTvd8syJh_W2CsHHM6V3JsMN8Yt_QHDcbCbf9809pT04</span></a>, a solar power generation specialist also involved in renewable energy measurement and management<br><br></span></span></li>

+

<li style="line-height:125%"><span style="color:#000000;font-size:15px"><span style="color:#000000;font-weight:normal"><strong>Nodle also won a parachain slot.</strong> <a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kC3qn9gW7lCdLW6lZ3ldW68FxlJ64-yd-W68WpmR7XxXn9W36mMk36LCYgnW2WPzjP62m3xwW8w3cwx2x_6W8W8JpHrW2VqrcbW8XzWf687HmzZVbrsYv5TM_NcVVDt2V8jStlbW7fWNRk3b-L72W70y7cT9885jtN3414y5PjbmfW6YRD912sQjYkW7d2Wpx7r5xm2W7hd3kL55jhx2W7s-rss7016kYVr68PF4dn7hvN3Q-7rwNTGKXW1q7Y_M7SjVC9W7VSXcn8S14MDW5V80vB8vGHcVW3p7TXb1-h4G1N4n3yMMdcZBpW78nDg08sXVTDf6YxDmv04" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">Nodle <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kC3qn9gW7lCdLW6lZ3ldW68FxlJ64-yd-W68WpmR7XxXn9W36mMk36LCYgnW2WPzjP62m3xwW8w3cwx2x_6W8W8JpHrW2VqrcbW8XzWf687HmzZVbrsYv5TM_NcVVDt2V8jStlbW7fWNRk3b-L72W70y7cT9885jtN3414y5PjbmfW6YRD912sQjYkW7d2Wpx7r5xm2W7hd3kL55jhx2W7s-rss7016kYVr68PF4dn7hvN3Q-7rwNTGKXW1q7Y_M7SjVC9W7VSXcn8S14MDW5V80vB8vGHcVW3p7TXb1-h4G1N4n3yMMdcZBpW78nDg08sXVTDf6YxDmv04</span></a> is a smart-phone powered network bringing Web3 to the physical world, enabling logistics companies, IoT startups and builders to tap into their network of millions of smart phones<br><br></span></span></li>

+

<li style="line-height:125%"><strong><span style="font-size:15px;color:#000000">Polkadot Blockchain Academy wave 4 will take place in Hong Kong in January and Singapore in May</span></strong><span style="font-size:15px;color:#000000">, and </span><a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3ljW75BrsG2mPdMrW4r7hRm6m7v9kN2dx1mkgGxbqW7H4hgh4mYBr7V3rZQP8N_klzW8KFxMP5NwtCyW4QxV--4-dbM0W5WGDfm29Pg8yW6ndpNG7SHVSPN4v-b5nCM69TW8J75kz7vGTfHW5VFXBW6XkZhhN5vNQQSl8pxgW2rdgJH4fr68sW8m97C38mj4LpF4gQ-BpqZ-VW8TkjlD1gGNCXW8TSRxC53ttRgW80-q7L4HLFnSW2KtQht35kRTMN3v6ksjcGyv1W7g07VN5hk-wSW41Bv8g7Rp-19W4TnLw76dv19dVZmzjK6zJ_GsN53sQDk8jp87f1j6rQ204" style="color:#e6007a;font-size:15px" target="_blank" rel="noreferrer" class="tmail-tooltip">applications are open now <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3ljW75BrsG2mPdMrW4r7hRm6m7v9kN2dx1mkgGxbqW7H4hgh4mYBr7V3rZQP8N_klzW8KFxMP5NwtCyW4QxV--4-dbM0W5WGDfm29Pg8yW6ndpNG7SHVSPN4v-b5nCM69TW8J75kz7vGTfHW5VFXBW6XkZhhN5vNQQSl8pxgW2rdgJH4fr68sW8m97C38mj4LpF4gQ-BpqZ-VW8TkjlD1gGNCXW8TSRxC53ttRgW80-q7L4HLFnSW2KtQht35kRTMN3v6ksjcGyv1W7g07VN5hk-wSW41Bv8g7Rp-19W4TnLw76dv19dVZmzjK6zJ_GsN53sQDk8jp87f1j6rQ204</span></a></li>

+

</ul>

+

<p style="line-height:175%">&nbsp;</p>

+

<p style="line-height:175%">&nbsp;</p>

+

<p style="line-height:175%;text-align:center" align="center"><span style="color:#000000">Interested in building on Polkadot?&nbsp;<a href="https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3mzW8hjNnY30nszCW8kcb6G6Sq605N6MLV49vfz6fW8x1pBB8GDkmYW2ZRh_x7-D8v0W3xtT1L87BVXNW1C3gqr97lwGJW2lKn3T1pr585W8rSqNH8tmg8YW838H6l6_v56CW1RVvRf8t5K7_N5fp4J628fRyW465ddZ2W26NtW1X87y993lSJtW2Bx2gm3FW8BbW25ycny55FrklW4BCD9P441TRtW1Kfn-78r_VP4VbjTG83CvYg6W3721FW7_0T4XW1QvvF08WWDQTW6FVRWd3DfSs1W2fLtxV7DJBnSW88ddkr2R01WVW93rPJv671J_CN1pGGbKH1wzRf5V1Yvn04" style="color:#000000" rel="noopener" target="_blank" class="tmail-tooltip">Talk to an expert! <span class="tooltiptext">https://cWcMY04.na1.hubspotlinks.com/Ctc/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4M9V8kW3qn9gW7Y8-PT6lZ3mzW8hjNnY30nszCW8kcb6G6Sq605N6MLV49vfz6fW8x1pBB8GDkmYW2ZRh_x7-D8v0W3xtT1L87BVXNW1C3gqr97lwGJW2lKn3T1pr585W8rSqNH8tmg8YW838H6l6_v56CW1RVvRf8t5K7_N5fp4J628fRyW465ddZ2W26NtW1X87y993lSJtW2Bx2gm3FW8BbW25ycny55FrklW4BCD9P441TRtW1Kfn-78r_VP4VbjTG83CvYg6W3721FW7_0T4XW1QvvF08WWDQTW6FVRWd3DfSs1W2fLtxV7DJBnSW88ddkr2R01WVW93rPJv671J_CN1pGGbKH1wzRf5V1Yvn04</span></a></span></p></div></div></td></tr></tbody></table>

+

<table cellpadding="0" cellspacing="0" width="100%" style="border-spacing:0!important;border-collapse:collapse"><tbody><tr><td class="m_403900111212837041hs_padded" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;padding:10px 20px"><div id="m_403900111212837041hs_cos_wrapper_module_16454453387282" style="color:inherit;font-size:inherit;line-height:inherit">

+


+


+


+


+


+


+


+

<table width="100%" cellpadding="0" cellspacing="0" style="border-spacing:0!important;border-collapse:collapse;font-family:Arial,sans-serif;font-size:12px;line-height:135%;color:#23496d;margin-bottom:0;padding:0">

+

    <tbody>

+

        <tr>

+

            <td align="center" valign="top" style="border-collapse:collapse;font-family:Lato,Tahoma,sans-serif;font-size:15px;color:#073763;word-break:break-word;text-align:center;margin-bottom:0;line-height:135%;padding:10px 20px">

+


+

                <p style="font-family:Arial,sans-serif;font-size:12px;font-weight:normal;text-decoration:none;font-style:normal;color:#000000;direction:lrt" dir="lrt">

+

                  Parity Technologies Ltd, c/o Ignition Law, <a href="https://www.google.com/maps/search/1+Sans+Walk,+London?entry=gmail&amp;source=g" target="_blank" rel="noreferrer" class="tmail-tooltip">1 Sans Walk, London <span class="tooltiptext">https://www.google.com/maps/search/1+Sans+Walk,+London?entry=gmail&amp;source=g</span></a>, London EC1R 0LT, United Kingdom

+

                </p>

+

                <p>

+


+

                  <a href="https://hs-7592558.s.hubspotemail.net/hs/manage-preferences/unsubscribe-all?languagePreference=en&amp;d=Vnh1wW6CMw7dW104cLw41S745W4fdK5l3zdr-JW1Q3gBf3_R592N1JxwY5WZdnkN1B5JqPNw12rW90PVhC7dDyyrVD4_2p6FSxnqW58QZF55rGkp4W1PRjpY7jDJDWW23xnLM1h-lc6W3hdcNY2xNH3Mf64FX1n04&amp;v=3&amp;utm_campaign=Polkadot%20Newsletter&amp;utm_source=hs_email&amp;utm_medium=email&amp;utm_content=276327668&amp;_hsenc=p2ANqtz-9S_ILXxIE2insn9et1dtx8rkQrJebIiCBYapU1EtuLBIJqMxyN3hFCxUCRHExkPAiOl2NUxr4EsnpY3-zN19IXF-jpEA-4CZP-Oq08ILuNpOw4eo8&amp;_hsmi=276327668" style="font-family:Helvetica,Arial,sans-serif;font-size:12px;color:#999999;font-weight:normal;text-decoration:underline;font-style:normal" target="_blank" rel="noreferrer" class="tmail-tooltip">Unsubscribe <span class="tooltiptext">https://hs-7592558.s.hubspotemail.net/hs/manage-preferences/unsubscribe-all?languagePreference=en&amp;d=Vnh1wW6CMw7dW104cLw41S745W4fdK5l3zdr-JW1Q3gBf3_R592N1JxwY5WZdnkN1B5JqPNw12rW90PVhC7dDyyrVD4_2p6FSxnqW58QZF55rGkp4W1PRjpY7jDJDWW23xnLM1h-lc6W3hdcNY2xNH3Mf64FX1n04&amp;v=3&amp;utm_campaign=Polkadot%20Newsletter&amp;utm_source=hs_email&amp;utm_medium=email&amp;utm_content=276327668&amp;_hsenc=p2ANqtz-9S_ILXxIE2insn9et1dtx8rkQrJebIiCBYapU1EtuLBIJqMxyN3hFCxUCRHExkPAiOl2NUxr4EsnpY3-zN19IXF-jpEA-4CZP-Oq08ILuNpOw4eo8&amp;_hsmi=276327668</span></a>

+


+

                  <a href="https://hs-7592558.s.hubspotemail.net/hs/manage-preferences/unsubscribe?languagePreference=en&amp;d=Vnh1wW6CMw7dW104cLw41S745W4fdK5l3zdr-JW1Q3gBf3_R592N1JxwY5WZdnkN1B5JqPNw12rW90PVhC7dDyyrVD4_2p6FSxnqW58QZF55rGkp4W1PRjpY7jDJDWW23xnLM1h-lc6W3hdcNY2xNH3Mf64FX1n04&amp;v=3&amp;utm_campaign=Polkadot%20Newsletter&amp;utm_source=hs_email&amp;utm_medium=email&amp;utm_content=276327668&amp;_hsenc=p2ANqtz-9S_ILXxIE2insn9et1dtx8rkQrJebIiCBYapU1EtuLBIJqMxyN3hFCxUCRHExkPAiOl2NUxr4EsnpY3-zN19IXF-jpEA-4CZP-Oq08ILuNpOw4eo8&amp;_hsmi=276327668" style="font-family:Helvetica,Arial,sans-serif;font-size:12px;color:#999999;font-weight:normal;text-decoration:underline;font-style:normal" target="_blank" rel="noreferrer" class="tmail-tooltip">Manage preferences <span class="tooltiptext">https://hs-7592558.s.hubspotemail.net/hs/manage-preferences/unsubscribe?languagePreference=en&amp;d=Vnh1wW6CMw7dW104cLw41S745W4fdK5l3zdr-JW1Q3gBf3_R592N1JxwY5WZdnkN1B5JqPNw12rW90PVhC7dDyyrVD4_2p6FSxnqW58QZF55rGkp4W1PRjpY7jDJDWW23xnLM1h-lc6W3hdcNY2xNH3Mf64FX1n04&amp;v=3&amp;utm_campaign=Polkadot%20Newsletter&amp;utm_source=hs_email&amp;utm_medium=email&amp;utm_content=276327668&amp;_hsenc=p2ANqtz-9S_ILXxIE2insn9et1dtx8rkQrJebIiCBYapU1EtuLBIJqMxyN3hFCxUCRHExkPAiOl2NUxr4EsnpY3-zN19IXF-jpEA-4CZP-Oq08ILuNpOw4eo8&amp;_hsmi=276327668</span></a>

+


+

                </p>

+


+

            </td>

+

        </tr>

+

    </tbody>

+

</table></div></td></tr></tbody></table>

+

</div>

+


+


+


+

    </div>

+


+

  </div>

+

</div>

+

          </td>

+

        </tr>

+

      </tbody></table>

+

    </div>

+


+

<img src="https://cWcMY04.na1.hubspotlinks.com/Cto/I6+113/cWcMY04/VV-1-R5tNq20W7d21DS6zKDmNW4Rs6st541dy4V9V84q8fYJHM2l31" alt="" width="1" height="1" border="0" style="display:none!important;min-height:1px!important;width:1px!important;border-width:0!important;margin-top:0!important;margin-bottom:0!important;margin-right:0!important;margin-left:0!important;padding-top:0!important;padding-bottom:0!important;padding-right:0!important;padding-left:0!important; max-width:100%;" loading="lazy"></div></div>

+ + diff --git a/sanitize_html/benchmark/fixtures/large_email_2.html b/sanitize_html/benchmark/fixtures/large_email_2.html new file mode 100644 index 00000000..6307dff5 --- /dev/null +++ b/sanitize_html/benchmark/fixtures/large_email_2.html @@ -0,0 +1,291 @@ + + + + + + + + + + + +

<div class="gmail_quote"><div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>From: <strong class="gmail_sendername" dir="auto">Techcombank</strong> <span dir="auto">&lt;<a href="mailto:no-reply@mail.techcombank.com" target="_blank" rel="noreferrer" class="tmail-tooltip">no-reply@mail.techcombank.com <span class="tooltiptext">mailto:no-reply@mail.techcombank.com</span></a>&gt;</span><br>Date: Tue, 3 Oct 2023 at 00:05<br>Subject: Thông báo lịch kiểm thử hoạt động của các dịch vụ công nghệ tại hệ thống dự phòng<br>To:  &lt;<a href="mailto:hoangdat.pham2911@gmail.com" target="_blank" rel="noreferrer" class="tmail-tooltip">hoangdat.pham2911@gmail.com <span class="tooltiptext">mailto:hoangdat.pham2911@gmail.com</span></a>&gt;<br></div><br><br><u></u>

+


+


+


+


+


+


+


+


+


+

 

+


+


+


+


+


+


+


+


+


+

  <div id="m_9212032853630751615archivebody" style="height:100%;margin:0;padding:0;width:100%;background-color:#fafafa">

+


+

<p></p>

+


+

<p><span class="m_9212032853630751615mcnPreviewText" style="display:none;font-size:0px;line-height:0px;max-height:0px;max-width:0px;opacity:0;overflow:hidden">Nhằm đảm bảo hoạt động kinh doanh liên tục, nâng cao chất lượng dịch vụ và tăng cường trải nghiệm cho khách hàng, Techcombank xin thông báo “Lịch kiểm thử hoạt động của các dịch vụ công nghệ tại hệ thống dự phòng”</span></p>

+


+

<p></p>

+


+

<table style="width:100%" width="100%">

+

<tbody>

+

<tr>

+

<td></td>

+

<td align="center" width="600">

+

<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="m_9212032853630751615bodyTable" style="border-collapse:collapse;height:100%;margin:0;padding:0;width:100%;background-color:#fafafa">

+

<tbody>

+

<tr>

+

<td align="center" valign="top" id="m_9212032853630751615bodyCell" style="height:100%;margin:0;padding:10px;width:100%;border-top:0">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" class="m_9212032853630751615templateContainer" style="border-collapse:collapse;border:0;max-width:600px!important">

+

<tbody>

+

<tr>

+

<td style="font-family:Arial,sans-serif;font-size:11px;color:#4c4c4c;text-align:center;line-height:16px"><p align="center">

+

  <font style="font-family:Verdana,Arial;font-size:10px">

+

  Vui lòng

+

  <a href="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3dea&amp;e=cDE9JTQwQnA4dXRzTjhQcnRDdU9veGdDU2ElMkJEJTJGTDR2eHlYYUV2RWpoSWZiZU1wRWMlM0Q&amp;s=zlZgHj48_6Xj3uwz9rFH0sqQX4O2qU8Yt8pn1laPCHA" target="_blank" rel="noreferrer" class="tmail-tooltip">nhấn vào đây. <span class="tooltiptext">https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3dea&amp;e=cDE9JTQwQnA4dXRzTjhQcnRDdU9veGdDU2ElMkJEJTJGTDR2eHlYYUV2RWpoSWZiZU1wRWMlM0Q&amp;s=zlZgHj48_6Xj3uwz9rFH0sqQX4O2qU8Yt8pn1laPCHA</span></a> nếu Quý khách không xem được thư điện tử này!</font>

+

</p></td>

+

</tr>

+

<tr>

+

<td valign="top" id="m_9212032853630751615templateHeader" style="background:#ffffff none no-repeat center/cover;background-color:#ffffff;background-image:none;background-repeat:no-repeat;background-position:center;background-size:cover;border-top:0;border-bottom:0;padding-top:9px;padding-bottom:0">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding:0px">

+

<table align="left" width="100%" border="0" cellpadding="0" cellspacing="0" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="word-break:break-word;color:#202020;font-family:Arial;font-size:16px;line-height:150%;text-align:left;padding:0 18px 9px 18px">

+

<div style="text-align:center"><a class="m_9212032853630751615email-link" href="#m_9212032853630751615_englishVersion" target="_blank" rel="noreferrer"><em>English below</em></a></div>

+

</td>

+

</tr>

+

<tr>

+

<td valign="top" style="text-align:center;padding:0 0px 0 0px"><img border="0" src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/techcombank_mid_prod2/2d83ab9c7bc14f94090f4268e2f792c457aadbb92d28dff6cf48f3f2d81212db.png" style="display:inline;max-width:100%;height:auto;" loading="lazy"></td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

<tr>

+

<td valign="top" id="m_9212032853630751615templateBody" style="background:#ffffff none no-repeat center/cover;background-color:#ffffff;background-image:none;background-repeat:no-repeat;background-position:center;background-size:cover;border-top:0;border-bottom:0;padding-top:0;padding-bottom:9px">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding-top:9px">

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" class="m_9212032853630751615mcnTextContent" style="word-break:break-word;color:#202020;font-family:Helvetica;font-size:16px;line-height:150%;text-align:left;padding:0 18px 9px 18px">

+

<p><span style="font-size:14px"><span style="font-size:14px"><span style="font-family:Helvetica"><strong>Kính gửi Quý khách,</strong><br>&nbsp;<br>Nhằm đảm bảo hoạt động kinh doanh liên tục, nâng cao chất lượng dịch vụ và tăng cường trải nghiệm cho khách hàng, Techcombank xin thông báo “Lịch kiểm thử hoạt động của các dịch vụ công nghệ tại hệ thống dự phòng” cụ thể như sau:</span></span></span></p>

+

<p><span style="font-size:14px"><span style="font-size:14px"><span style="font-family:Helvetica"><span><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;height:auto;" width="15" loading="lazy"> Chuyển các dịch vụ sang hệ thống dự phòng: Từ 0:15 AM – 6:00 AM, ngày 07/10/2023; <br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> Chuyển các dịch vụ về hệ thống chính: Từ 0:15 AM – 6:00 AM, ngày 08/10/2023.</span><br></span></span></span><span style="font-size:14px"><span style="font-size:14px"><span style="font-family:Helvetica">&nbsp;<br>Trong khoảng thời gian nói trên, dịch vụ thuộc các kênh sau sẽ tạm thời gián đoạn, cụ thể: </span></span></span></p>

+

<p><span style="font-size:14px"><span style="font-size:14px"><span style="font-family:Helvetica"><span><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;height:auto;" width="15" loading="lazy"> Với khách hàng Cá nhân: Các giao dịch qua ứng dụng Ngân hàng điện tử Techcombank Mobile và Techcombank Online Banking.; <br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> Với khách hàng Doanh nghiệp: Các giao dịch qua nền tảng Website và Ứng dụng di động của Ngân hàng số Techcombank Business; Internet banking F@st EBank; dịch vụ kết nối H2H; QR Code Collection và dịch vụ tài trợ chuỗi cung ứng.<br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> Các giao dịch qua hệ thống thẻ của Techcombank trừ các giao dịch qua thẻ Visa Credit. <br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> Các giao dịch thanh toán qua TCBPay.</span><br></span></span></span></p>

+

<table border="0" cellpadding="0" cellspacing="0" width="562" height="5" style="min-width:100%;border-collapse:collapse;width:99.6454%">

+

<tbody></tbody>

+

</table>

+

<span style="font-size:14px">Techcombank xin thông báo để quý khách có kế hoạch để thực hiện các giao dịch và rất mong quý khách thông cảm về những gián đoạn trong khoảng thời gian nói trên.</span>

+

<p><span style="font-size:14px"><span>Thông tin về lịch kiểm thử sẽ được cập nhật tại website Techcombank và Facebook Techcombank Việt Nam. <br>Trong trường hợp cần hỗ trợ, quý khách vui lòng liên hệ:</span></span></p>

+

<p><span style="font-size:14px"><span>Với khách hàng cá nhân:</span></span></p>

+

<p><span style="font-size:14px"><span style="font-family:Helvetica"><span><span>💌 Email đến <span style="text-align:justify"><a href="mailto:call_center@techcombank.com.vn" target="_blank" rel="noreferrer" class="tmail-tooltip">call_center@techcombank.com.vn <span class="tooltiptext">mailto:call_center@techcombank.com.vn</span></a></span><br>☎️&nbsp;<span style="text-align:justify">Trung Tâm Dịch Vụ Khách Hàng (hotline 24/7): 1800 588822 (trong nước) hoặc 84-24-39446699 (quốc tế)</span></span></span></span></span></p>

+

<p><span style="font-size:14px"><span>Với khách hàng doanh nghiệp:</span></span></p>

+

<p><span style="font-size:14px"><span style="font-family:Helvetica"><span><span>💌 Email đến <a href="mailto:Hotrodoanhnghiep@techcombank.com.vn" target="_blank" rel="noreferrer" class="tmail-tooltip">Hotrodoanhnghiep@techcombank.com.vn <span class="tooltiptext">mailto:Hotrodoanhnghiep@techcombank.com.vn</span></a> hoặc <a href="mailto:wb.support@techcombank.com.vn" target="_blank" rel="noreferrer" class="tmail-tooltip">wb.support@techcombank.com.vn <span class="tooltiptext">mailto:wb.support@techcombank.com.vn</span></a> (DN lớn). <br>☎️&nbsp;Trung Tâm Dịch Vụ Khách Hàng - Hotline 24/7: 1800.6556 (trong nước) hoặc +84.24.7303.6556 (quốc tế) </span></span></span></span></p>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

</tbody>

+

</table>

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" style="word-break:break-word;padding:0 18px 9px 18px"></td>

+

</tr>

+

</tbody>

+

</table>

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding-top:9px">

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" class="m_9212032853630751615mcnTextContent" style="word-break:break-word;color:#202020;font-family:Helvetica;font-size:16px;line-height:150%;text-align:left;padding:0 18px 9px 18px"><span style="font-family:Helvetica"><span style="font-size:14px"> Cảm ơn quý khách đã tin tưởng và đồng hành cùng Techcombank trong hành trình vượt trội hơn mỗi ngày. </span></span> <br><br><span style="font-family:Helvetica"><span style="font-size:14px">Trân trọng,<br><strong>Ngân hàng TMCP Kỹ Thương Việt Nam</strong></span></span></td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

</tbody>

+

</table>

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" class="m_9212032853630751615mcnDividerBlock" style="min-width:100%;border-collapse:collapse;table-layout:fixed!important">

+

<tbody>

+

<tr>

+

<td style="min-width:100%;padding:18px">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-top:2px solid #eaeaea;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td><span></span></td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

<tr>

+

<td valign="top" id="m_9212032853630751615templateHeader" style="background:#ffffff none no-repeat center/cover;background-color:#ffffff;background-image:none;background-repeat:no-repeat;background-position:center;background-size:cover;border-top:0;border-bottom:0;padding-top:9px;padding-bottom:0">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding:0px"><img border="0" src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/techcombank_mid_prod2/03c4b4e2b4defabbcbd9632f40629788fe0e8bd870c9323b010bb9cae7f17dbf.png" style="display:inline;max-width:100%;height:auto;" loading="lazy"></td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

<tr>

+

<td valign="top" id="m_9212032853630751615templateBody" style="background:#ffffff none no-repeat center/cover;background-color:#ffffff;background-image:none;background-repeat:no-repeat;background-position:center;background-size:cover;border-top:0;border-bottom:2px solid #eaeaea;padding-top:0;padding-bottom:9px">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding-top:9px">

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse;width:100%" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" class="m_9212032853630751615mcnTextContent" style="word-break:break-word;color:rgb(32,32,32);font-family:Helvetica;font-size:16px;line-height:150%;text-align:left;padding:0px 18px 9px;width:100%">

+

<p><span style="font-size:14px"><span style="font-size:14px"><span style="font-family:Helvetica"><strong>Dear Valued Customers,</strong><br>&nbsp;<br>In order to ensure business continuity, improve service quality and enhance customer experience, Techcombank would like to announce "Testing schedule of technology services at backup system" as follows: <br><span><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> From 00:15 – 06:00 AM October 7th 2023<br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> From 00:15 – 06:00 AM October 8th 2023 .</span><br></span></span></span></p>

+

<p><span style="font-size:14px"><span style="font-size:14px"><span style="font-family:Helvetica">During this time, some features will be out of service, specifically:<br><span><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> For Individual Customers: Transactions via Techcombank Mobile, Techcombank Online Banking <br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> For Corporate Customers: Transactions via Website and Mobile Application platforms of Techcombank Business, F@st EBank, H2H connection service, QR Code Collection, and Supply Chain Finance <br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> Transactions via Techcombank's Card system except via Visa Credit card <br><img src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/BFB9F42F64718470FA178193B25CF6BD.png" style="border:0px initial;width:15px;height:15px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="15" loading="lazy"> Transactions via TCBPay.</span><br></span></span></span></p>

+

<span style="font-size:14px">We would like to express our apology for the inconvenience.</span>

+

<p><span style="font-size:14px"><span>Information about the testing schedule will be continuously updated on our official communication channels including Techcombank website and Techcombank Vietnam fanpage. <br>In case you need support, please contact: </span></span></p>

+

<p><span style="font-size:14px"><span>For Individual Customers:</span></span></p>

+

<p><span style="font-size:14px"><span style="font-family:Helvetica"><span><span>💌 Email to <span style="text-align:justify"><a href="mailto:call_center@techcombank.com.vn" target="_blank" rel="noreferrer" class="tmail-tooltip">call_center@techcombank.com.vn <span class="tooltiptext">mailto:call_center@techcombank.com.vn</span></a></span><br>☎️ <span style="text-align:justify">Hotline: 1800.588.822 (domestic) or +84.24.3944.6699 (international) </span></span></span></span></span></p>

+

<p><span style="font-size:14px"><span>For Corporate Customers:</span></span></p>

+

<p><span style="font-size:14px"><span style="font-family:Helvetica"><span><span>💌 Email: <a href="mailto:Hotrodoanhnghiep@techcombank.com.vn" target="_blank" rel="noreferrer" class="tmail-tooltip">Hotrodoanhnghiep@techcombank.com.vn <span class="tooltiptext">mailto:Hotrodoanhnghiep@techcombank.com.vn</span></a> or <a href="mailto:WB.support@techcombank.com.vn" target="_blank" rel="noreferrer" class="tmail-tooltip">WB.support@techcombank.com.vn <span class="tooltiptext">mailto:WB.support@techcombank.com.vn</span></a> (large enterprises) <br>☎️ Hotline: 1800.6556 (domestic) or +84.24.7303.6556 (international)</span></span></span></span></p>

+

<p><span style="font-size:14px"><span style="font-family:Helvetica"><span><span><span>Thank you for your understanding and we h</span><span>ope you will accompany </span><span>Techcombank’s</span><span> Be Greater journey.</span></span></span></span></span></p>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

</tbody>

+

</table>

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" style="word-break:break-word;padding:0 18px 9px 18px"></td>

+

</tr>

+

</tbody>

+

</table>

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding-top:9px">

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" class="m_9212032853630751615mcnTextContent" style="word-break:break-word;color:#202020;font-family:Helvetica;font-size:16px;line-height:150%;text-align:left;padding:0 18px 9px 18px"><span style="font-family:Helvetica"><span style="font-size:14px">Sincerely, <br><strong>Vietnam Technological and Commercial Joint Stock Bank</strong></span></span></td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

<tr>

+

<td valign="top" id="m_9212032853630751615templateFooter" style="background:#fafafa none no-repeat center/cover;background-color:#fafafa;background-image:none;background-repeat:no-repeat;background-position:center;background-size:cover;border-top:0;border-bottom:0;padding-top:9px;padding-bottom:9px">

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse">

+

<tbody>

+

<tr>

+

<td valign="top" style="padding-top:9px">

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr>

+

<td valign="top" class="m_9212032853630751615mcnTextContent" style="word-break:break-word;color:#656565;font-size:12px;line-height:150%;text-align:center;padding:0 18px 9px 18px">

+

<table align="center" border="0" cellpadding="0" cellspacing="0" width="60%" style="border-collapse:collapse">

+

<tbody>

+

<tr height="45">

+

<td height="45" style="text-align:center" width="77"><a href="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3deb" style="color:#656565;font-weight:normal;text-decoration:underline" rel="noopener" target="_blank"><img height="30" src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/9A4FDBA7F003CEC56990499745515089.png" style="border:0px initial;width:30px;height:30px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="30" loading="lazy"></a></td>

+

<td style="text-align:center" width="77"><a href="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3dec" style="color:#656565;font-weight:normal;text-decoration:underline" rel="noopener" target="_blank"><img height="30" src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/702ACFBEEF6F6F1842A61D2D7CFDCA61.png" style="border:0px;width:30px;height:30px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="30" loading="lazy"></a></td>

+

<td style="text-align:center" width="77"><a href="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3ded" style="color:#656565;font-weight:normal;text-decoration:underline" rel="noopener" target="_blank"><img height="30" src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/624B89C115E6F91E776C9BF7FF0B2279.png" style="border:0px initial;width:30px;height:30px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="30" loading="lazy"></a></td>

+

<td style="text-align:center" width="77"><a href="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3dee" style="color:#656565;font-weight:normal;text-decoration:underline" rel="noopener" target="_blank"><img height="30" src="https://techcombank-mid-prod2-res.adobe-campaign.com/res/img/4BEA83313D92FD1C4AE663B8BD37850C.png" style="border:0px initial;width:30px;height:30px;margin:0px;outline:none;text-decoration:none display:inline; max-width:100%;" width="30" loading="lazy"></a></td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

</tbody>

+

</table>

+

<table border="0" cellpadding="0" cellspacing="0" width="100%" style="min-width:100%;border-collapse:collapse;height:349px">

+

<tbody>

+

<tr style="height:333px">

+

<td valign="top" style="padding-top:9px;height:333px">

+

<table align="left" border="0" cellpadding="0" cellspacing="0" style="max-width:100%;min-width:100%;border-collapse:collapse;height:321px" width="100%" class="m_9212032853630751615mcnTextContentContainer">

+

<tbody>

+

<tr style="height:321px">

+

<td valign="top" class="m_9212032853630751615mcnTextContent" style="word-break:break-word;color:rgb(101,101,101);font-size:12px;line-height:150%;text-align:center;padding:0px 18px 9px;height:321px"><em>Copyright © 2023 Techcombank, All rights reserved.</em><br>Sở dĩ Quý khách nhận được thư điện tử này bởi vì Quý khách đã chấp thuận cho Ngân hàng TMCP Kỹ Thương Việt Nam (Techcombank) gửi đến cho Quý khách các thông tin và chương trình khuyến mãi liên quan đến sản phẩm và dịch vụ của Techcombank. Quý khách có quyền chọn lựa không nhận các thông tin và vật phẩm quảng cáo về dịch vụ Techcombank bất kỳ lúc nào.<br><br><strong>Địa chỉ:</strong><br>

+

<div><span>Techcombank</span>

+

<div>

+

<div>6 Quang Trung, Tran Hung Dao, Hoan Kiem, Ha Noi</div>

+

<span>Ha Noi</span>, <span>VN</span> <span>100000</span>

+

<div>Vietnam</div>

+

</div>

+

</div>

+

<div><span>Quý khách có thể cập nhật thông tin hoặc từ chối nhận

+

<a href="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3def&amp;e=cDE9JTQwQlIlMkJnS2p3YmlmVExDY0MzNiUyQnpUbWclM0QlM0Q&amp;s=RwwCJYTkS4EPAfAYF_j0sro54dUtqUfnkfDF87pF7qQ" target="_blank" rel="noreferrer" class="tmail-tooltip">tại đây <span class="tooltiptext">https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,3def&amp;e=cDE9JTQwQlIlMkJnS2p3YmlmVExDY0MzNiUyQnpUbWclM0QlM0Q&amp;s=RwwCJYTkS4EPAfAYF_j0sro54dUtqUfnkfDF87pF7qQ</span></a>

+

&nbsp;</span></div>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

<tr style="height:16px">

+

<td style="font-family:Arial,sans-serif;font-size:11px;color:rgb(76,76,76);text-align:center;line-height:16px;height:16px">&nbsp;</td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

</tr>

+

</tbody>

+

</table>

+

 </td>

+

</tr>

+

</tbody>

+

</table>

+

</td>

+

<td></td>

+

</tr>

+

</tbody>

+

</table>

+

<img height="0" width="0" alt="" src="https://techcombank-mid-prod2-t.adobe-campaign.com/r/?id=h23e908e,29f6e,1" style="display:inline;max-width:100%;height:auto;" loading="lazy"></div>

+


+


+

</div>

+ + diff --git a/sanitize_html/benchmark/sanitize_benchmark.dart b/sanitize_html/benchmark/sanitize_benchmark.dart new file mode 100644 index 00000000..5848093a --- /dev/null +++ b/sanitize_html/benchmark/sanitize_benchmark.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:sanitize_html/src/sane_html_validator.dart'; + +final List sampleHtmlDocuments = [ + // Short HTML + '

Hello world

', + // Medium HTML + ''' +
+

Click me

+ + link +
+ ''', + // Large HTML + File('benchmark/fixtures/large_email_1.html').readAsStringSync(), + File('benchmark/fixtures/large_email_2.html').readAsStringSync(), +]; + +void main() { + const iterations = 200; + + final sane = SaneHtmlValidator( + allowElementId: null, + allowClassName: null, + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ); + + _warmup(sane); + + final saneDuration = + _benchmark('SaneHtmlValidator', sane.sanitize, iterations); + + final saneMs = saneDuration.inMilliseconds; + + print('--- Benchmark result ---'); + print('SaneHtmlValidator : $saneMs ms'); +} + +void _warmup( + SaneHtmlValidator sane, +) { + for (final html in sampleHtmlDocuments) { + sane.sanitize(html); + } +} + +Duration _benchmark( + String label, + String Function(String) sanitize, + int iterations, +) { + final sw = Stopwatch()..start(); + + for (var i = 0; i < iterations; i++) { + for (final html in sampleHtmlDocuments) { + sanitize(html); + } + } + + sw.stop(); + print('$label finished in ${sw.elapsed.inMilliseconds} ms'); + return sw.elapsed; +} diff --git a/sanitize_html/lib/src/attribute_policy.dart b/sanitize_html/lib/src/attribute_policy.dart new file mode 100644 index 00000000..1cae2faa --- /dev/null +++ b/sanitize_html/lib/src/attribute_policy.dart @@ -0,0 +1,98 @@ +import 'package:html/dom.dart'; +import 'package:sanitize_html/src/css_sanitizer.dart'; +import 'package:sanitize_html/src/html_sanitize_config.dart'; +import 'package:sanitize_html/src/url_validators.dart'; + +class AttributePolicy { + static final Map> _validators = + _preNormalizeTagValidators(); + + static Map> + _preNormalizeTagValidators() { + final out = >{}; + AttributePolicy.tagAttributeValidators.forEach((tag, attrs) { + final normalized = tag.toUpperCase(); + final newMap = {}; + attrs.forEach((a, fn) { + newMap[a.toLowerCase()] = fn; + }); + out[normalized] = newMap; + }); + return out; + } + + static final tagAttributeValidators = + >{ + 'A': {'href': UrlValidators.validLink}, + 'IMG': { + 'src': UrlValidators.validImageSource, + 'longdesc': UrlValidators.validImageSource, + }, + 'DIV': { + 'itemscope': (_) => true, + 'itemtype': (_) => true, + }, + 'BLOCKQUOTE': {'cite': UrlValidators.validUrl}, + 'DEL': {'cite': UrlValidators.validUrl}, + 'INS': {'cite': UrlValidators.validUrl}, + 'Q': {'cite': UrlValidators.validUrl}, + }; + + static bool isSafeId(String id) => + HtmlSanitizeConfig.safeIdPattern.hasMatch(id); + + static bool isSafeClass(String c) => + HtmlSanitizeConfig.safeClassPattern.hasMatch(c); + + static bool sanitizeAttribute( + Element node, + String attr, + String value, { + bool Function(String)? allowId, + bool Function(String)? allowClass, + }) { + final a = attr.toLowerCase(); + + if (HtmlSanitizeConfig.forbiddenAttributes.contains(a)) return false; + + if (a == 'id') { + return allowId != null ? allowId(value) : isSafeId(value); + } + + if (a == 'class') { + final original = node.className; + if (original.isEmpty) return false; + + final parts = original.split(' '); + node.classes.clear(); + + for (final c in parts) { + if (c.isEmpty) continue; + final ok = allowClass != null ? allowClass(c) : isSafeClass(c); + if (ok) node.classes.add(c); + } + + return node.classes.isNotEmpty; + } + + if (a == 'style') { + final sanitized = CssSanitizer.sanitizeInline(value); + if (sanitized.isEmpty) return false; + node.attributes['style'] = sanitized; + return true; + } + + if (HtmlSanitizeConfig.alwaysAllowedAttributes.contains(a)) return true; + + final tagName = node.localName?.toUpperCase(); + if (tagName != null) { + final validators = _validators[tagName]; + if (validators != null) { + final fn = validators[a]; + if (fn != null) return fn(value); + } + } + + return false; + } +} diff --git a/sanitize_html/lib/src/css_sanitizer.dart b/sanitize_html/lib/src/css_sanitizer.dart new file mode 100644 index 00000000..63939722 --- /dev/null +++ b/sanitize_html/lib/src/css_sanitizer.dart @@ -0,0 +1,71 @@ +import 'package:sanitize_html/src/html_sanitize_config.dart'; + +class CssSanitizer { + static bool isSafeCssValue(String value) { + final lower = value.toLowerCase(); + for (final f in HtmlSanitizeConfig.forbiddenCss) { + if (lower.contains(f)) return false; + } + return true; + } + + static String sanitizeInline(String raw) { + raw = raw.trim(); + if (raw.isEmpty) return ''; + + final buf = StringBuffer(); + + final items = raw.split(';'); + for (var d in items) { + d = d.trim(); + if (d.isEmpty) continue; + + final colon = d.indexOf(':'); + if (colon <= 0) continue; + + final prop = d.substring(0, colon).trim().toLowerCase(); + final val = d.substring(colon + 1).trim(); + + if (!HtmlSanitizeConfig.allowedCssProperties.contains(prop)) continue; + if (!isSafeCssValue(val)) continue; + + if (buf.isNotEmpty) buf.write('; '); + buf.write(prop); + buf.write(': '); + buf.write(val); + } + + return buf.toString(); + } + + static String sanitizeStylesheet(String css) { + css = css.replaceAll(HtmlSanitizeConfig.cssCommentPattern, '').trim(); + if (css.isEmpty) return ''; + + final buffer = StringBuffer(); + final blocks = css.split('}'); + + for (var block in blocks) { + block = block.trim(); + if (block.isEmpty) continue; + + final brace = block.indexOf('{'); + if (brace <= 0) continue; + + final selector = block.substring(0, brace).trim(); + if (selector.startsWith('@')) continue; + + final declarations = block.substring(brace + 1).trim(); + final sanitized = sanitizeInline(declarations); + if (sanitized.isEmpty) continue; + + buffer + ..write(selector) + ..write(' { ') + ..write(sanitized) + ..writeln(' }'); + } + + return buffer.toString().trim(); + } +} diff --git a/sanitize_html/lib/src/html_sanitize_config.dart b/sanitize_html/lib/src/html_sanitize_config.dart new file mode 100644 index 00000000..38c75f15 --- /dev/null +++ b/sanitize_html/lib/src/html_sanitize_config.dart @@ -0,0 +1,384 @@ +class HtmlSanitizeConfig { + static const Set allowedElements = { + ..._allowedElementsBase, + }; + + static const Set _allowedElementsBase = { + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + 'H7', + 'H8', + 'BR', + 'B', + 'I', + 'STRONG', + 'EM', + 'A', + 'PRE', + 'CODE', + 'IMG', + 'TT', + 'DIV', + 'INS', + 'DEL', + 'SUP', + 'SUB', + 'P', + 'OL', + 'UL', + 'TABLE', + 'THEAD', + 'TBODY', + 'TFOOT', + 'BLOCKQUOTE', + 'DL', + 'DT', + 'DD', + 'KBD', + 'Q', + 'SAMP', + 'VAR', + 'HR', + 'RUBY', + 'RT', + 'RP', + 'LI', + 'TR', + 'TD', + 'TH', + 'S', + 'STRIKE', + 'SUMMARY', + 'DETAILS', + 'CAPTION', + 'FIGURE', + 'FIGCAPTION', + 'ABBR', + 'BDO', + 'CITE', + 'DFN', + 'MARK', + 'SMALL', + 'SPAN', + 'TIME', + 'WBR', + 'FONT', + 'U', + 'CENTER', + 'SECTION', + 'COLGROUP', + 'COL', + 'NAV', + 'MAIN', + 'FOOTER', + 'STYLE', + }; + + static const Set alwaysAllowedAttributes = { + ..._alwaysAllowedAttributesBase, + }; + + static const Set _alwaysAllowedAttributesBase = { + 'abbr', + 'accept', + 'accesskey', + 'align', + 'alt', + 'aria-describedby', + 'aria-hidden', + 'aria-label', + 'aria-labelledby', + 'axis', + 'border', + 'cellpadding', + 'cellspacing', + 'char', + 'charoff', + 'checked', + 'clear', + 'cols', + 'colspan', + 'color', + 'compact', + 'coords', + 'datetime', + 'dir', + 'disabled', + 'for', + 'frame', + 'headers', + 'height', + 'hreflang', + 'hspace', + 'ismap', + 'label', + 'lang', + 'maxlength', + 'media', + 'multiple', + 'name', + 'nohref', + 'noshade', + 'nowrap', + 'open', + 'prompt', + 'readonly', + 'rel', + 'rev', + 'rows', + 'rowspan', + 'rules', + 'scope', + 'selected', + 'shape', + 'size', + 'span', + 'start', + 'summary', + 'tabindex', + 'title', + 'type', + 'usemap', + 'valign', + 'value', + 'vspace', + 'width', + 'itemprop', + 'style', + 'bgcolor', + 'data-filename', + 'public-asset-id', + 'data-mimetype', + }; + + static const Set forbiddenTags = { + ..._forbiddenTagsBase, + }; + + static const Set _forbiddenTagsBase = { + 'SCRIPT', + 'IFRAME', + 'OBJECT', + 'EMBED', + 'APPLET', + 'INPUT', + 'BUTTON', + 'TEXTAREA', + 'SELECT', + 'OPTION', + }; + + static const Set forbiddenAttributes = { + ..._forbiddenEventHandlers, + ..._forbiddenFormAttributes, + }; + + static const Set _forbiddenEventHandlers = { + 'onclick', + 'ondblclick', + 'onmousedown', + 'onmouseup', + 'onmouseover', + 'onmouseenter', + 'onmouseleave', + 'onmousemove', + 'onmouseout', + 'onkeydown', + 'onkeypress', + 'onkeyup', + 'onfocus', + 'onblur', + 'onfocusin', + 'onfocusout', + 'onchange', + 'oninput', + 'onsubmit', + 'onreset', + 'oninvalid', + 'oncopy', + 'oncut', + 'onpaste', + 'ondrag', + 'ondragstart', + 'ondragend', + 'ondrop', + 'ondragover', + 'ondragenter', + 'ondragleave', + 'ontouchstart', + 'ontouchmove', + 'ontouchend', + 'ontouchcancel', + 'onabort', + 'oncanplay', + 'oncanplaythrough', + 'oncuechange', + 'ondurationchange', + 'onemptied', + 'onended', + 'onerror', + 'onloadeddata', + 'onloadedmetadata', + 'onloadstart', + 'onpause', + 'onplay', + 'onplaying', + 'onprogress', + 'onratechange', + 'onseeked', + 'onseeking', + 'onstalled', + 'onsuspend', + 'ontimeupdate', + 'onvolumechange', + 'onwaiting', + 'onload', + 'onbeforeunload', + 'onafterprint', + 'onbeforeprint', + 'onresize', + 'onscroll', + 'onselect', + 'onselectstart', + }; + + static const Set _forbiddenFormAttributes = { + 'action', + 'formaction', + 'method', + 'formmethod', + 'target', + 'formtarget', + 'enctype', + 'formenctype', + 'accept-charset', + 'autocomplete', + 'novalidate', + 'srcdoc', + }; + + static const Set allowedCssProperties = { + 'color', + 'background-color', + 'font-family', + 'font-size', + 'font-weight', + 'font-style', + 'font-variant', + 'font-stretch', + 'line-height', + 'text-align', + 'text-decoration', + 'text-transform', + 'text-indent', + 'letter-spacing', + 'white-space', + 'word-wrap', + 'word-break', + 'overflow-wrap', + 'text-overflow', + 'vertical-align', + 'direction', + 'unicode-bidi', + 'margin', + 'margin-left', + 'margin-right', + 'margin-top', + 'margin-bottom', + 'padding', + 'padding-left', + 'padding-right', + 'padding-top', + 'padding-bottom', + 'border', + 'border-style', + 'border-color', + 'border-width', + 'border-top', + 'border-right', + 'border-bottom', + 'border-left', + 'border-radius', + 'border-collapse', + 'border-spacing', + 'display', + 'width', + 'min-width', + 'max-width', + 'height', + 'min-height', + 'max-height', + 'box-sizing', + 'table-layout', + 'caption-side', + 'empty-cells', + 'list-style', + 'list-style-type', + 'list-style-position', + 'fill', + 'stroke', + 'stroke-width', + }; + + static const Set svgMathmlTags = { + 'svg', + 'path', + 'g', + 'rect', + 'circle', + 'polyline', + 'polygon', + 'animate', + 'foreignobject', + 'math', + 'mstyle', + 'mscript', + 'mprescripts', + 'mfenced', + }; + + static const List dangerousPatternsHtmlMarkup = [ + '<', + '>', + '', + '">'; + final out = validator.sanitize(html); + + expect(out.contains(' true, + allowClassName: (_) => true, + addLinkRel: (_) => ['nofollow'], + allowAttributes: null, + allowTags: null, + ); + }); + + test('mixed complex snippet with many vectors sanitized safely', () { + const html = ''' +
+ + +
+ +

+ Hello +

+ + + + World + + link +
+
+ '''; + + final out = validator.sanitize(html); + + expect(out.contains('onclick='), false); + expect(out.contains(' id == 'only-allowed-id', - allowClassName: (className) => className == 'only-allowed-class', addLinkRel: (href) => href == 'bad-link' ? ['ugc', 'nofollow'] : null, ); } @@ -68,18 +66,11 @@ void main() { // test id filtering.. testContains('hello', 'id'); testContains('hello', 'only-allowed-id'); - testNotContains('hello', 'id'); - testNotContains('hello', 'only-allowed-id'); // test class filtering testContains('hello', 'class'); testContains( 'hello', 'only-allowed-class'); - testContains('hello', - 'class="only-allowed-class"'); - testNotContains('hello', 'class'); - testNotContains( - 'hello', 'only-allowed-class'); testContains('hello', 'href'); testContains('hello', 'test.html'); @@ -129,7 +120,6 @@ void main() { testNotContains('
', '
'); testNotContains('
', ''); testContains('><', '><'); - testContains('
a
', '
a
'); testContains('
ab', 'ab'); @@ -152,9 +142,5 @@ void main() { withOptionalConfiguration: false); testNotContains('hey', 'rel=', withOptionalConfiguration: false); - testNotContains('hello', 'id=', - withOptionalConfiguration: false); - testNotContains('hello', 'class=', - withOptionalConfiguration: false); }); } diff --git a/sanitize_html/test/url_validators_test.dart b/sanitize_html/test/url_validators_test.dart new file mode 100644 index 00000000..24273624 --- /dev/null +++ b/sanitize_html/test/url_validators_test.dart @@ -0,0 +1,116 @@ +import 'package:sanitize_html/src/url_validators.dart'; +import 'package:test/test.dart'; + +void main() { + group('UrlValidators', () { + test('validLink accepts http/https/mailto', () { + expect(UrlValidators.validLink('https://google.com'), true); + expect(UrlValidators.validLink('http://example.com'), true); + expect(UrlValidators.validLink('mailto:test@example.com'), true); + }); + + test('validLink rejects javascript', () { + expect(UrlValidators.validLink('javascript:alert(1)'), false); + }); + + test('validUrl accepts http/https', () { + expect(UrlValidators.validUrl('https://abc.com'), true); + expect(UrlValidators.validUrl('http://xyz.com'), true); + }); + + test('validUrl rejects mailto', () { + expect(UrlValidators.validUrl('mailto:abc'), false); + }); + + test('validBase64Image works correctly', () { + expect( + UrlValidators.validBase64Image( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA', + ), + true, + ); + + expect( + UrlValidators.validBase64Image('data:image/png;base64,INVALID##'), + false, + ); + }); + + test('validBase64Image Valid Base64 PNG image string', () { + String validBase64PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + expect(UrlValidators.validBase64Image(validBase64PNG), isTrue); + }); + + test('validBase64Image Valid Base64 JPEG image string', () { + String validBase64JPEG = + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAAAAAA'; + expect(UrlValidators.validBase64Image(validBase64JPEG), isTrue); + }); + + test('validBase64Image Invalid Base64 image string (missing data:image/)', + () { + String invalidBase64 = 'base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + expect(UrlValidators.validBase64Image(invalidBase64), isFalse); + }); + + test('validBase64Image Invalid Base64 image string (not base64 encoded)', + () { + String invalidBase64 = 'data:image/png;notabase64string'; + expect(UrlValidators.validBase64Image(invalidBase64), isFalse); + }); + + test('validBase64Image Valid Base64 SVG image string', () { + String validBase64SVG = + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDov'; + expect(UrlValidators.validBase64Image(validBase64SVG), isTrue); + }); + + test('validBase64Image Invalid Base64 image string (wrong image type)', () { + String invalidBase64Type = + 'data:image/tiff;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; + expect(UrlValidators.validBase64Image(invalidBase64Type), isFalse); + }); + + test('validBase64Image Empty string', () { + String emptyString = ''; + expect(UrlValidators.validBase64Image(emptyString), isFalse); + }); + + test('validBase64Image Non-image Base64 string', () { + String nonImageBase64 = 'data:text/plain;base64,dGVzdA=='; + expect(UrlValidators.validBase64Image(nonImageBase64), isFalse); + }); + + test('validCIDImage works correctly', () { + expect(UrlValidators.validCIDImage('cid:12345'), true); + expect(UrlValidators.validCIDImage('abc:12345'), false); + }); + + test('validCIDImage returns true for valid cid string', () { + expect(UrlValidators.validCIDImage('cid:12345'), true); + }); + + test('validCIDImage returns false for string without cid', () { + expect( + UrlValidators.validCIDImage('https://example.com/image.png'), + false, + ); + }); + + test('validCIDImage returns false for empty string', () { + expect(UrlValidators.validCIDImage(''), false); + }); + + test('validImageSource covers URL/Base64/CID', () { + expect(UrlValidators.validImageSource('cid:12'), true); + expect(UrlValidators.validImageSource('https://abc.com'), true); + expect( + UrlValidators.validImageSource( + 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD', + ), + true, + ); + }); + }); +} diff --git a/sanitize_html/test/validate_base64_image_test.dart b/sanitize_html/test/validate_base64_image_test.dart deleted file mode 100644 index 33d34271..00000000 --- a/sanitize_html/test/validate_base64_image_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:sanitize_html/src/sane_html_validator.dart'; -import 'package:test/test.dart'; - -void main() { - group('validateBase64Image', () { - test('Valid Base64 PNG image string', () { - String validBase64PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; - expect(validateBase64Image(validBase64PNG), isTrue); - }); - - test('Valid Base64 JPEG image string', () { - String validBase64JPEG = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAAAAAA'; - expect(validateBase64Image(validBase64JPEG), isTrue); - }); - - test('Invalid Base64 image string (missing data:image/)', () { - String invalidBase64 = 'base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; - expect(validateBase64Image(invalidBase64), isFalse); - }); - - test('Invalid Base64 image string (not base64 encoded)', () { - String invalidBase64 = 'data:image/png;notabase64string'; - expect(validateBase64Image(invalidBase64), isFalse); - }); - - test('Valid Base64 SVG image string', () { - String validBase64SVG = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDov'; - expect(validateBase64Image(validBase64SVG), isTrue); - }); - - test('Invalid Base64 image string (wrong image type)', () { - String invalidBase64Type = 'data:image/tiff;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; - expect(validateBase64Image(invalidBase64Type), isFalse); - }); - - test('Empty string', () { - String emptyString = ''; - expect(validateBase64Image(emptyString), isFalse); - }); - - test('Non-image Base64 string', () { - String nonImageBase64 = 'data:text/plain;base64,dGVzdA=='; // Plain text Base64-encoded - expect(validateBase64Image(nonImageBase64), isFalse); - }); - }); -} diff --git a/sanitize_html/test/validate_cid_image_test.dart b/sanitize_html/test/validate_cid_image_test.dart deleted file mode 100644 index 8cc0879a..00000000 --- a/sanitize_html/test/validate_cid_image_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:sanitize_html/src/sane_html_validator.dart'; -import 'package:test/test.dart'; - -void main() { - group('validateCIDImage', () { - test('returns true for valid cid string', () { - expect(validateCIDImage('cid:12345'), true); - }); - - test('returns false for string without cid', () { - expect(validateCIDImage('https://example.com/image.png'), false); - }); - - test('returns false for empty string', () { - expect(validateCIDImage(''), false); - }); - }); -} From dc329abcf6de19477d5cd15966ed867ffdd3e524 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 3 Dec 2025 15:37:19 +0700 Subject: [PATCH 06/23] feat(sanitize-html): add document for Secure HTML Sanitization Engine --- .../docs/html_sanitization_engine.md | 339 ++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 sanitize_html/docs/html_sanitization_engine.md diff --git a/sanitize_html/docs/html_sanitization_engine.md b/sanitize_html/docs/html_sanitization_engine.md new file mode 100644 index 00000000..913f8021 --- /dev/null +++ b/sanitize_html/docs/html_sanitization_engine.md @@ -0,0 +1,339 @@ +# Secure HTML Sanitization Engine + +## 1. Motivation and Goals +Rendering HTML from untrusted sources (emails, rich text editors, integrations, external systems, etc.) is inherently dangerous. Raw HTML can be weaponized to: +- Execute arbitrary JavaScript (XSS) +- Exfiltrate sensitive data +- Spoof UI to steal user credentials (phishing) +- Inject invisible overlays and hijack input +- Break the layout of the application + +The legacy sanitizer from `dart-neats` bundled validation logic into a single monolithic class and was harder to extend, test, and reason about. + +This PR introduces a **modular, secure, email-optimized HTML sanitization pipeline** with: +- Strong XSS protection +- Email-specific URL and image rules +- Safe CSS filtering +- Clear three-tier tag classification +- Configurable but security-validated overrides +- Better structure for maintainability and testing + +--- + +## 2. High-Level Architecture + +The sanitizer is split into independent modules: + +### **SaneHtmlValidator** +Public façade that receives HTML and returns sanitized output. Performs: +- Input parsing +- Override validation +- Delegation to NodeSanitizer + +### **NodeSanitizer** +Core DOM walker implementing: +- Tag classification (allowed, forbidden, disallowed/unwrap) +- Attribute sanitization +- Inline and block CSS sanitization +- Per-tag validation logic +- Safe ID/class rules + +### **HtmlSanitizeConfig** +Centralized policy storage: +- Allowed tags +- Forbidden tags +- Always-allowed attributes +- Forbidden attributes +- Allowed CSS properties +- Forbidden CSS tokens +- Safe ID/class patterns +- URL/image validation patterns + +### **AttributePolicy** +Maps element types to attribute validation: +- `` URL validation +- `` base64 / CID / http rules +- Microdata attributes +- Link rel augmentation hooks + +### **CssSanitizer** +Responsible for: +- Sanitizing inline style declarations +- Sanitizing `', ); - sanitizer.sanitize(fragment, allowUnwrap: false); + if (doc.head != null) sanitizer.sanitize(doc.head!, allowUnwrap: false); expect( - fragment.outerHtml.trim(), + doc.head!.innerHtml.trim(), '', ); }); diff --git a/sanitize_html/test/sane_html_validator_test.dart b/sanitize_html/test/sane_html_validator_test.dart index 6e2ba84f..80074a22 100644 --- a/sanitize_html/test/sane_html_validator_test.dart +++ b/sanitize_html/test/sane_html_validator_test.dart @@ -164,14 +164,14 @@ void main() { }); test( - 'valid base64 PNG data URL preserved exactly (newline and tab allowed)', + 'valid base64 PNG data URL formated exactly (newline and tab removed)', () { const html = ''; final out = validator.sanitize(html); expect( out, - '', + '', reason: 'Base64 data URL must be preserved as-is (Roundcube compatibility)', ); @@ -188,23 +188,18 @@ void main() { final out = validator.sanitize( '', ); - expect(out.contains(''), true); + expect(out, contains('')); + expect(out, isNot(contains('src="data:image/png;base64,INVALID@!'))); }); - test('SVG data URLs in img src are currently preserved', () { + test('SVG data URLs in img src are currently dropped', () { final out = validator.sanitize( '', ); - expect( - out.contains('src="data:image/svg+xml;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=="'), - true, - ); - - expect( - out.contains(' remains, but src is dropped + expect(out, contains('')); + expect(out, isNot(contains('data:image/svg+xml'))); }); test('invalid image URLs removed (javascript inside src)', () { @@ -971,42 +966,6 @@ void main() { expect(out.contains('javascript:'), false); }); - test('inline CSS with base64 SVG payload is preserved (dangerous SVG not yet detected)', () { - const html = ''' -

- Hi -

- '''; - - final out = validator.sanitize(html); - - // SVG trong CSS hiện tại được giữ lại, vì sanitizer chưa decode SVG và chưa chặn trong CssSanitizer. - expect( - out.contains('data:image/svg+xml'), - true, - reason: 'CSS sanitizer currently allows svg+xml inside url(...) since no SVG danger detection is implemented.', - ); - - expect( - out.contains('base64,'), - true, - reason: 'Base64 payload is preserved by current CssSanitizer behavior.', - ); - - // Không có decode nên không detect “alert(” → không thể assert false - expect( - out.contains('alert('), - false, - reason: 'No JS should appear in output HTML structure.', - ); - - expect( - out.contains('Hi'), - true, - reason: 'Non-dangerous text content must always be preserved.', - ); - }); - test('HTML comment injection cannot break attribute parsing', () { const html = ''; final out = validator.sanitize(html); @@ -1107,18 +1066,6 @@ void main() { expect(out, contains('href="http://test.com"')); }); - test('data:image – preserve base64 even with newlines', () { - const html = - '

'; - - final out = validator.sanitize(html); - - expect( - out, - '

', - ); - }); - test('AREA – remove data:, vbscript:, javascript: in href', () { const html = '' '

' @@ -1440,7 +1387,9 @@ void main() { expect(out, contains('Click the arrow')); }); - test('Keep JS functions, DOM access, addEventListener, even alert() in code samples', () { + test( + 'Keep JS functions, DOM access, addEventListener, even alert() in code samples', + () { const html = '''

JavaScript Best Practices

@@ -1699,7 +1648,7 @@ void main() { out, isNot(contains('style=')), reason: - 'When all CSS properties are forbidden, the style attribute should be removed.', + 'When all CSS properties are forbidden, the style attribute should be removed.', ); expect(out, isNot(contains('position'))); @@ -1716,18 +1665,21 @@ void main() { expect( out, contains('color: red'), - reason: 'Safe CSS properties in the same style attribute must be preserved.', + reason: + 'Safe CSS properties in the same style attribute must be preserved.', ); expect( out, isNot(contains('background-image')), - reason: 'background-image using javascript: in url() must be dropped.', + reason: + 'background-image using javascript: in url() must be dropped.', ); expect( out, isNot(contains('url(')), - reason: 'Unsafe url() declarations must not leak into sanitized output.', + reason: + 'Unsafe url() declarations must not leak into sanitized output.', ); expect( out, diff --git a/sanitize_html/test/sanitize_html_test.dart b/sanitize_html/test/sanitize_html_test.dart index 9f4b786f..0658cf0a 100644 --- a/sanitize_html/test/sanitize_html_test.dart +++ b/sanitize_html/test/sanitize_html_test.dart @@ -12,13 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:test/test.dart'; import 'package:sanitize_html/sanitize_html.dart' show sanitizeHtml; +import 'package:test/test.dart'; void main() { // Calls sanitizeHtml with two different configurations. - // * When `withOptionalConfiguration` is `true`: `allowElementId`, `allowClassName` - // and `addLinkRel` overrides are passed to the sanitizeHtml call of `template`. + // * When `withOptionalConfiguration` is `true`: + // `addLinkRel` overrides are passed to the sanitizeHtml call of `template`. // (This is the default behavior for the [testContains]/[testNotContains] methods.) // * When `withOptionalConfiguration` is false: only `template` is passed. String doSanitizeHtml(String template, @@ -63,18 +63,9 @@ void main() { testContains('

hello', '

'); testContains('

hello', '

'); - // test id filtering.. - testContains('hello', 'id'); - testContains('hello', 'only-allowed-id'); - - // test class filtering - testContains('hello', 'class'); - testContains( - 'hello', 'only-allowed-class'); - testContains('hello', 'href'); testContains('hello', 'test.html'); - testContains( + testNotContains( 'hello', '//example.com/test.html'); testContains('hello', '/test.html'); testContains('hello', @@ -91,7 +82,7 @@ void main() { testContains('say hi', 'alt='); testContains('', 'src='); testContains('', 'test.jpg'); - testContains('', '//test.jpg'); + testNotContains('', '//test.jpg'); testContains('', '/test.jpg'); testContains('', 'https://example.com/test.jpg'); @@ -143,4 +134,256 @@ void main() { testNotContains('hey', 'rel=', withOptionalConfiguration: false); }); + + group('URL Encoding and Whitespace Handling', () { + group('Valid URL Encoding - Should Be Preserved', () { + test('preserves %20 (encoded space) in href', () { + const html = + 'Link'; + final result = sanitizeHtml(html); + + expect(result, + contains('href="https://example.com/path%20with%20spaces"')); + expect(result, contains('Link')); + }); + + test('preserves multiple URL-encoded characters', () { + const html = + 'Link'; + final result = sanitizeHtml(html); + + expect( + result, contains('https://example.com/file%20name%2Ftest%3Fquery')); + }); + + test('preserves %20 in img src', () { + const html = + 'test'; + final result = sanitizeHtml(html); + + expect(result, contains('src="https://example.com/image%20file.png"')); + }); + + test('preserves query parameters with encoded spaces', () { + const html = + 'Search'; + final result = sanitizeHtml(html); + + expect(result, contains('q=hello%20world')); + }); + + test('preserves complex URL encoding', () { + const html = + 'Link'; + final result = sanitizeHtml(html); + + expect(result, contains('name=John%20Doe')); + expect(result, contains('city=New%20York')); + }); + + test('preserves %2B (encoded plus) and %26 (encoded ampersand)', () { + const html = + 'Link'; + final result = sanitizeHtml(html); + + expect(result, contains('q=c%2B%2B%26python')); + }); + + test('preserves URL-encoded Unicode characters', () { + const html = + 'Chinese'; + final result = sanitizeHtml(html); + + expect(result, contains('%E4%B8%AD%E6%96%87')); + }); + + test('preserves fragment identifiers with encoding', () { + const html = + 'Link'; + final result = sanitizeHtml(html); + + expect(result, contains('#section%20name')); + }); + }); + + group('Actual Whitespace in URLs - Should Be Removed (XSS Prevention)', () { + test('removes actual spaces in javascript: URL (XSS)', () { + const html = 'Click'; + final result = sanitizeHtml(html); + + // The whitespace should be removed, making it "javascript:" + // which should then be blocked + expect(result, isNot(contains('javascript:'))); + expect(result, isNot(contains('java script'))); + expect(result, contains('Click')); + }); + + test('removes newlines in javascript: URL (XSS)', () { + const html = 'Click'; + final result = sanitizeHtml(html); + + expect(result, isNot(contains('javascript:'))); + expect(result, contains('Click')); + }); + + test('removes tabs in javascript: URL (XSS)', () { + const html = 'Click'; + final result = sanitizeHtml(html); + + expect(result, isNot(contains('javascript:'))); + expect(result, contains('Click')); + }); + + test('removes multiple whitespace types in URL', () { + const html = 'Click'; + final result = sanitizeHtml(html); + + expect(result, isNot(contains('javascript:'))); + expect(result, contains('Click')); + }); + + test('removes spaces in data: URL (XSS)', () { + const html = + 'Click'; + final result = sanitizeHtml(html); + + expect(result, isNot(contains('data:'))); + expect(result, isNot(contains('Link'; + final result = sanitizeHtml(html); + + // %20 should be preserved, actual space should be removed + expect(result, contains('%20')); + expect(result, contains('encodedandactualspace')); + }); + + test('handles URL with both encoded and unencoded characters', () { + const html = + 'Link'; + final result = sanitizeHtml(html); + + // Encoded space preserved + expect(result, contains('hello%20world')); + // Actual space removed + expect(result, contains('newstuff')); + }); + }); + + group('Special Cases', () { + test('preserves mailto: with encoded spaces', () { + const html = + 'Email'; + final result = sanitizeHtml(html); + + expect(result, contains('subject=Hello%20World')); + }); + + test('preserves relative URLs with encoding', () { + const html = 'Link'; + final result = sanitizeHtml(html); + + expect(result, contains('/path/to/file%20name.html')); + }); + + test('handles empty href gracefully', () { + const html = 'Empty'; + final result = sanitizeHtml(html); + + expect(result, contains('Empty')); + }); + + test('handles href with only whitespace (should be removed)', () { + const html = 'Whitespace'; + final result = sanitizeHtml(html); + + // Whitespace removed, leaving empty/invalid href + expect(result, contains('Whitespace')); + expect(result, isNot(contains('href='))); + }); + }); + + group('CSS Background URLs with Encoding', () { + test('preserves %20 in CSS background-image', () { + const html = + '

Test
'; + final result = sanitizeHtml(html); + + expect(result, contains('bg%20image.png')); + }); + + test('preserves encoded query params in CSS url()', () { + const html = + '
Test
'; + final result = sanitizeHtml(html); + + expect(result, contains('size=large%20')); + }); + }); + + group('Edge Cases and Security', () { + test('blocks %00 (null byte) in URL', () { + // Null byte can be used to truncate URLs in some contexts + const html = 'Link'; + final result = sanitizeHtml(html); + + // URL should still be present (not a security issue in modern browsers) + // but we document the behavior + expect(result, contains('Link')); + }); + + test('handles double-encoding attempts', () { + // %2520 is double-encoded space (%25 = %, so %2520 = %20) + const html = 'Link'; + final result = sanitizeHtml(html); + + // Double-encoding should be preserved as-is + expect(result, contains('%2520')); + }); + + test('handles percent sign followed by non-hex characters', () { + const html = 'Link'; + final result = sanitizeHtml(html); + + // Invalid percent-encoding, but not a security issue + expect(result, contains('50%discount')); + }); + + test('preserves international domain names with punycode', () { + const html = 'Russian'; + final result = sanitizeHtml(html); + + expect(result, contains('xn--e1afmkfd.xn--p1ai')); + }); + }); + + group('Regression Tests - Ensure XSS Blocked', () { + test('blocks javascript: even with URL encoding', () { + // %6A = j, %61 = a, %76 = v, %61 = a, %73 = s, %63 = c, %72 = r, %69 = i, %70 = p, %74 = t + const html = + 'Click'; + final result = sanitizeHtml(html); + + // This might not be blocked by our sanitizer since we check lowercase + // but URL decoders would turn it into javascript: + // Document this behavior + expect(result, contains('Click')); + }); + + test('blocks data:text/html with encoding', () { + const html = + 'Click'; + final result = sanitizeHtml(html); + + expect(result, isNot(contains('data:text/html'))); + expect(result, contains('Click')); + }); + }); + }); } diff --git a/sanitize_html/test/url_validators_test.dart b/sanitize_html/test/url_validators_test.dart index 24273624..d2e30525 100644 --- a/sanitize_html/test/url_validators_test.dart +++ b/sanitize_html/test/url_validators_test.dart @@ -22,20 +22,6 @@ void main() { expect(UrlValidators.validUrl('mailto:abc'), false); }); - test('validBase64Image works correctly', () { - expect( - UrlValidators.validBase64Image( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA', - ), - true, - ); - - expect( - UrlValidators.validBase64Image('data:image/png;base64,INVALID##'), - false, - ); - }); - test('validBase64Image Valid Base64 PNG image string', () { String validBase64PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; @@ -60,18 +46,18 @@ void main() { expect(UrlValidators.validBase64Image(invalidBase64), isFalse); }); - test('validBase64Image Valid Base64 SVG image string', () { - String validBase64SVG = - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDov'; - expect(UrlValidators.validBase64Image(validBase64SVG), isTrue); - }); - test('validBase64Image Invalid Base64 image string (wrong image type)', () { String invalidBase64Type = 'data:image/tiff;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA'; expect(UrlValidators.validBase64Image(invalidBase64Type), isFalse); }); + test('validBase64Image rejects SVG data URLs', () { + const svgData = + 'data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIj48L3N2Zz4='; + expect(UrlValidators.validBase64Image(svgData), isFalse); + }); + test('validBase64Image Empty string', () { String emptyString = ''; expect(UrlValidators.validBase64Image(emptyString), isFalse); @@ -82,11 +68,6 @@ void main() { expect(UrlValidators.validBase64Image(nonImageBase64), isFalse); }); - test('validCIDImage works correctly', () { - expect(UrlValidators.validCIDImage('cid:12345'), true); - expect(UrlValidators.validCIDImage('abc:12345'), false); - }); - test('validCIDImage returns true for valid cid string', () { expect(UrlValidators.validCIDImage('cid:12345'), true); }); From a47497e5de62f05b283be628c9542b7787abdfe8 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 15 Dec 2025 10:46:01 +0700 Subject: [PATCH 15/23] fix(sanitize): keep non-executable JS-like content in text nodes --- .../lib/src/html_sanitize_config.dart | 3 +- sanitize_html/lib/src/node_sanitizer.dart | 23 ++-- .../test/sane_html_validator_test.dart | 127 +++++++++++++++--- 3 files changed, 118 insertions(+), 35 deletions(-) diff --git a/sanitize_html/lib/src/html_sanitize_config.dart b/sanitize_html/lib/src/html_sanitize_config.dart index 6d512029..9eaa9cd0 100644 --- a/sanitize_html/lib/src/html_sanitize_config.dart +++ b/sanitize_html/lib/src/html_sanitize_config.dart @@ -422,7 +422,6 @@ class HtmlSanitizeConfig { 'behavior', '-moz-binding', '-webkit-binding', - '@import', ]; // @@ -461,7 +460,7 @@ class HtmlSanitizeConfig { static final RegExp base64ValuePattern = RegExp(r'^[A-Za-z0-9+/=]+$'); static final RegExp dangerousMarkupRegex = RegExp( - r'(') || - lower.contains(']]>') || - lower.contains('document.cookie')) { - return true; - } + // Strip literal CDATA markers (especially inside SVG) + if (lower.contains('')) { + return true; + } - // Text inside SVG treated as CDATA-like script - if (parentTag == 'svg') { - if (lower.contains('alert(') || - lower.contains('function(') || - lower.contains('document.cookie')) { - return true; - } + // Parser-breaking patterns + if (lower.contains(' - - - - '''; - - final out = validator.sanitize(html); - - expect(out.contains('CDATA'), false); - expect(out.contains('alert'), false); - expect(out.contains(' @@ -1711,5 +1693,114 @@ void main() { expect(out, contains('height="100"')); }); }); + + group('Text nodes with JS-like content (non-executable)', () { + late SaneHtmlValidator validator; + + setUp(() { + validator = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: (_) => ['nofollow'], + allowAttributes: null, + allowTags: null, + ); + }); + + test('KEEPS plain text containing document.cookie', () { + const html = 'hello

document.cookie

world'; + expect( + validator.sanitize(html), + equals('hello

document.cookie

world'), + ); + }); + + test('KEEPS plain text containing document.location', () { + const html = 'see

document.location

for details'; + expect( + validator.sanitize(html), + equals('see

document.location

for details'), + ); + }); + + test('KEEPS plain text containing window.location', () { + const html = '

window.location.href

'; + expect( + validator.sanitize(html), + equals('

window.location.href

'), + ); + }); + + test('KEEPS plain text containing document.write reference', () { + const html = '

Avoid using document.write in production

'; + expect( + validator.sanitize(html), + equals('

Avoid using document.write in production

'), + ); + }); + + test('KEEPS plain text that looks like JS function call', () { + const html = '

alert("test")

'; + expect( + validator.sanitize(html), + equals('

alert("test")

'), + ); + }); + + test('KEEPS plain text with function keyword', () { + const html = '

function test() { return 1; }

'; + expect( + validator.sanitize(html), + equals('

function test() { return 1; }

'), + ); + }); + + test('KEEPS JS keywords inside code tag', () { + const html = 'document.cookie'; + expect( + validator.sanitize(html), + equals('document.cookie'), + ); + }); + + test('KEEPS JS keywords inside preformatted text', () { + const html = '
if (document.cookie) { /* ... */ }
'; + expect( + validator.sanitize(html), + equals('
if (document.cookie) { /* ... */ }
'), + ); + }); + + test('KEEPS plain text JS keywords inside SVG text node', () { + const html = ''' + + document.cookie + +'''; + + expect( + validator.sanitize(html).contains('document.cookie'), + true, + ); + }); + + // Control cases: executable contexts must still be removed + + test('REMOVES document.cookie inside script tag', () { + const html = ''; + expect( + validator.sanitize(html), + equals(''), + ); + }); + + test('REMOVES document.cookie inside onclick attribute', () { + const html = ''; + expect( + validator.sanitize(html).contains('onclick'), + false, + ); + }); + }); }); } From 6627e13f2236bdea5d25f648e6d55b214a4569df Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 15 Dec 2025 14:29:17 +0700 Subject: [PATCH 16/23] Bump version to v3.0.0 --- sanitize_html/CHANGELOG.md | 3 +++ sanitize_html/pubspec.yaml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sanitize_html/CHANGELOG.md b/sanitize_html/CHANGELOG.md index f3d7fb05..90322d5c 100644 --- a/sanitize_html/CHANGELOG.md +++ b/sanitize_html/CHANGELOG.md @@ -1,3 +1,6 @@ +## v3.0.0 +* Add secure, high-performance HTML sanitization engine + ## v2.1.0 * Remove custom HTML rendering logic in favor of logic from `package:html`. * Added `topics` to `pubspec.yaml`. diff --git a/sanitize_html/pubspec.yaml b/sanitize_html/pubspec.yaml index 3b176b78..12330dde 100644 --- a/sanitize_html/pubspec.yaml +++ b/sanitize_html/pubspec.yaml @@ -1,5 +1,5 @@ name: sanitize_html -version: 2.1.0 +version: 3.0.0 description: >- Function for sanitizing HTML to prevent XSS by restrict elements and attributes to a safe subset of allowed values. From cb19cbd711b38eb53b1eed6ea3591f24525a31cd Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 24 Dec 2025 12:53:00 +0700 Subject: [PATCH 17/23] sanitize_html: preserve and safely sanitize internal CSS --- sanitize_html/lib/src/css_sanitizer.dart | 6 + sanitize_html/lib/src/extracted_style.dart | 9 + sanitize_html/lib/src/node_sanitizer.dart | 11 +- .../lib/src/sane_html_validator.dart | 63 +++- .../test/sane_html_validator_test.dart | 339 ++++++++++++++++++ 5 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 sanitize_html/lib/src/extracted_style.dart diff --git a/sanitize_html/lib/src/css_sanitizer.dart b/sanitize_html/lib/src/css_sanitizer.dart index 90a82a6f..e167d2ab 100644 --- a/sanitize_html/lib/src/css_sanitizer.dart +++ b/sanitize_html/lib/src/css_sanitizer.dart @@ -344,6 +344,12 @@ class CssSanitizer { // Remove top-level comments (outside declaration blocks) css = _stripTopLevelCssComments(css).trim(); + // Strip @import statements safely + css = css.replaceAll( + RegExp(r'@import\s+[^;]+;', caseSensitive: false), + '', + ); + final buffer = StringBuffer(); // Split by block } diff --git a/sanitize_html/lib/src/extracted_style.dart b/sanitize_html/lib/src/extracted_style.dart new file mode 100644 index 00000000..a03e8f33 --- /dev/null +++ b/sanitize_html/lib/src/extracted_style.dart @@ -0,0 +1,9 @@ +class ExtractedStyle { + final String css; + final String? media; + + const ExtractedStyle({ + required this.css, + this.media, + }); +} diff --git a/sanitize_html/lib/src/node_sanitizer.dart b/sanitize_html/lib/src/node_sanitizer.dart index cba21bb0..6a4d00fa 100644 --- a/sanitize_html/lib/src/node_sanitizer.dart +++ b/sanitize_html/lib/src/node_sanitizer.dart @@ -80,14 +80,13 @@ class NodeSanitizer { final lower = text.toLowerCase(); // Strip literal CDATA markers (especially inside SVG) - if (lower.contains('')) { - return true; - } + if (lower.contains('')) { + return true; + } // Parser-breaking patterns - if (lower.contains(' tags. + /// - Preserves order + /// - Removes '; + }).join('\n'); + } + /// Sanitizes HTML: /// - strips comments /// - parses DOM @@ -107,12 +146,34 @@ class SaneHtmlValidator { final noComments = _stripHtmlComments(html); final document = html_parser.parse(noComments); + // Extract internal CSS + final extractedStyles = extractStyleTags(document); + + // Sanitize body final body = document.body; if (body == null) return ''; _nodeSanitizer.sanitize(body, allowUnwrap: false); + // Sanitize CSS + final safeStyles = extractedStyles.map((s) { + final safeCss = CssSanitizer.sanitizeStylesheet(s.css); + return ExtractedStyle(css: safeCss, media: s.media); + }).toList(); + + // Rebuild HTML + final styleBlock = rebuildStyleBlock(safeStyles); + final output = body.innerHtml.trim(); - return output.isEmpty ? '' : output; + if (output.isEmpty) return ''; + + // Avoid triple-quote indentation artifacts. + // Keep styleBlock only when non-empty and always return a trimmed result. + final pieces = [ + if (styleBlock.trim().isNotEmpty) styleBlock.trim(), + output, + ]; + + return pieces.join('\n').trim(); } } diff --git a/sanitize_html/test/sane_html_validator_test.dart b/sanitize_html/test/sane_html_validator_test.dart index 5dcbd84e..09238173 100644 --- a/sanitize_html/test/sane_html_validator_test.dart +++ b/sanitize_html/test/sane_html_validator_test.dart @@ -1802,5 +1802,344 @@ void main() { ); }); }); + + group('SaneHtmlValidator – internal CSS', () { + late SaneHtmlValidator sanitizer; + + setUp(() { + sanitizer = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: (_) => null, + allowAttributes: null, + allowTags: null, + ); + }); + + test('preserves internal + + +
Hello
+ + +'''; + + final result = sanitizer.sanitize(html); + + expect(result, contains(' + + + +
Text
+ + +'''; + + final result = sanitizer.sanitize(html); + + final firstIndex = result.indexOf('.a { color: red }'); + final secondIndex = result.indexOf('.b { color: blue }'); + + expect(firstIndex, isNot(-1)); + expect(secondIndex, isNot(-1)); + expect(firstIndex < secondIndex, isTrue); + }); + + test('preserves + + +
Hidden
+ + +'''; + + final result = sanitizer.sanitize(html); + + expect( + result, + contains(' + + +
Test
+ + +'''; + + final result = sanitizer.sanitize(html); + + expect(result, contains(' + + +

Hello

+ + +'''; + + final result = sanitizer.sanitize(html); + + final styleCount = RegExp('', () { + const html = ''' + + +
Hello
+ +'''; + + final result = sanitizer.sanitize(html); + + expect(result, contains(' + + +
Hello
+ + +'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains(' + + +
Test
+ + +'''; + + final result = sanitizer.sanitize(html); + + expect(result, contains(' +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('javascript'))); + }); + + test('CSS XSS: removes expression()', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('expression'))); + }); + + test('CSS XSS: removes @import remote css', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('@import'))); + expect(result, contains('.x')); + }); + + test('CSS XSS: removes obfuscated @import', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result.toLowerCase(), isNot(contains('@import'))); + }); + + test('CSS XSS: removes behavior property', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('behavior'))); + }); + + test('CSS XSS: removes -moz-binding', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('-moz-binding'))); + }); + + test('CSS XSS: blocks data:text/html', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('data:text/html'))); + }); + + test('CSS XSS: allows data:image/png when configured', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, contains('data:image/png')); + }); + + test('CSS XSS: strips escaped javascript sequences', () { + const html = r''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('javascript'))); + }); + + test('CSS XSS: prevents injection', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('')); + expect(result, contains('color: red')); + }); + + test('CSS XSS: blocks svg javascript in css url()', () { + const html = ''' + +
X
+'''; + + final result = sanitizer.sanitize(html); + + expect(result, isNot(contains('onload'))); + }); + }); }); } From 87ac388d0955e073cb17420628276b24b98bc055 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 24 Dec 2025 13:56:13 +0700 Subject: [PATCH 18/23] sanitize_html: Fix potential attribute injection via unescaped media attribute. --- .../lib/src/sane_html_validator.dart | 22 ++++++++++++++----- .../test/sane_html_validator_test.dart | 4 ++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/sanitize_html/lib/src/sane_html_validator.dart b/sanitize_html/lib/src/sane_html_validator.dart index 51d685b0..361412d9 100644 --- a/sanitize_html/lib/src/sane_html_validator.dart +++ b/sanitize_html/lib/src/sane_html_validator.dart @@ -130,11 +130,20 @@ class SaneHtmlValidator { if (styles.isEmpty) return ''; return styles.map((s) { - final mediaAttr = s.media != null ? ' media="${s.media}"' : ''; + final mediaAttr = + s.media != null ? ' media="${_escapeAttribute(s.media!)}"' : ''; return '${s.css}'; }).join('\n'); } + String _escapeAttribute(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + } + /// Sanitizes HTML: /// - strips comments /// - parses DOM @@ -156,10 +165,13 @@ class SaneHtmlValidator { _nodeSanitizer.sanitize(body, allowUnwrap: false); // Sanitize CSS - final safeStyles = extractedStyles.map((s) { - final safeCss = CssSanitizer.sanitizeStylesheet(s.css); - return ExtractedStyle(css: safeCss, media: s.media); - }).toList(); + final safeStyles = extractedStyles + .map((s) { + final safeCss = CssSanitizer.sanitizeStylesheet(s.css); + return ExtractedStyle(css: safeCss, media: s.media); + }) + .where((s) => s.css.isNotEmpty) + .toList(); // Rebuild HTML final styleBlock = rebuildStyleBlock(safeStyles); diff --git a/sanitize_html/test/sane_html_validator_test.dart b/sanitize_html/test/sane_html_validator_test.dart index 09238173..121dc750 100644 --- a/sanitize_html/test/sane_html_validator_test.dart +++ b/sanitize_html/test/sane_html_validator_test.dart @@ -1900,7 +1900,7 @@ void main() { final result = sanitizer.sanitize(html); - expect(result, contains(' +
+'''; + + final out = sanitizer.sanitize(html); + + expect(out, contains('.get-app {')); + expect(out, contains('.inner')); + }); + }); + + group('CSS sanitizer – mixed flat and nested CSS', () { + late SaneHtmlValidator validator; + + setUp(() { + validator = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: (_) => null, + allowAttributes: null, + allowTags: null, + ); + }); + + test('preserves flat CSS and nested CSS together', () { + const html = ''' + +
+
X
+
+'''; + + final out = validator.sanitize(html); + + // Flat CSS preserved + expect(out, contains('.flat')); + expect(out, contains('color: red')); + + // Nested CSS preserved as raw text + expect(out, contains('.parent {')); + expect(out, contains('.child')); + + // HTML preserved + expect(out, contains('
')); + }); + + test('sanitizes flat CSS while preserving nested CSS', () { + const html = ''' + +
+
+
+'''; + + final out = validator.sanitize(html); + + // Flat CSS: javascript: must be removed + expect(out, contains('.safe')); + expect(out, isNot(contains('javascript:alert(1)'))); + + // Nested CSS: preserved but token stripped + expect(out, contains('.inner')); + expect(out, isNot(contains('javascript:alert(2)'))); + }); + + test('does not normalize nested CSS structure', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + // Preserve original formatting intent + expect(out, contains('.nested')); + expect(out, contains('margin-top: 10px')); + }); + + test('strips @import but preserves remaining flat and nested CSS', () { + const html = ''' + +
+
+
+'''; + + final out = validator.sanitize(html); + + // @import removed + expect(out.toLowerCase(), isNot(contains('@import'))); + + // Flat CSS preserved + expect(out, contains('.flat')); + expect(out, contains('color: green')); + + // Nested CSS preserved + expect(out, contains('.container')); + expect(out, contains('.item')); + }); + + test('preserves nested CSS when no flat CSS exists', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('.outer')); + expect(out, contains('.inner')); + expect(out, contains('width: 100%')); + }); + + test('preserves nested CSS inside media query', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + // media query preserved + expect(out, contains('@media')); + expect(out, contains('.box')); + expect(out, contains('.item')); + }); + + test('mixed CSS does not break base64 urls', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('data:image/png;base64,AAAABBBB')); + expect(out, contains('data:image/png;base64,CCCCDDDD')); + }); + + test('strips javascript: from nested CSS url()', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + // Nested structure preserved + expect(out, contains('.parent')); + expect(out, contains('.child')); + + // Dangerous token removed + expect(out.toLowerCase(), isNot(contains('javascript:'))); + }); + + test('strips expression() from nested CSS', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('.inner')); + expect(out.toLowerCase(), isNot(contains('expression'))); + }); + + test('strips @import in nested CSS block', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + // @import must be removed + expect(out.toLowerCase(), isNot(contains('@import'))); + + // Remaining CSS preserved + expect(out, contains('.wrapper')); + expect(out, contains('.item')); + }); + + test('blocks data:text/html in nested CSS url()', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('.inner')); + expect(out.toLowerCase(), isNot(contains('data:text/html'))); + }); + + test('strips svg onload payload from nested CSS', () { + const html = ''' + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('.icon')); + expect(out.toLowerCase(), isNot(contains('onload'))); + }); + + test('prevents "; + } +} + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('.inner')); + expect(out.toLowerCase(), isNot(contains(' +.box { + .item { + background-image: url(j\61vascript:alert(1)); + } +} + +
+'''; + + final out = validator.sanitize(html); + + expect(out, contains('.item')); + expect(out.toLowerCase(), isNot(contains('javascript'))); + }); + + test('preserve-mode blocks non-image data: URLs', () { + const html = ''' + +
+'''; + final out = validator.sanitize(html); + expect(out.toLowerCase(), isNot(contains('data:application/pdf'))); + }); }); }); } From 1247d360da32826145fceb42ca7efdeebc8ab6a4 Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 29 Dec 2025 10:48:23 +0700 Subject: [PATCH 20/23] fix(sanitizer): mitigate potential ReDoS in regex patterns --- sanitize_html/lib/src/attribute_policy.dart | 2 +- .../lib/src/css_dangerous_patterns.dart | 26 +++++++++---------- sanitize_html/lib/src/css_sanitizer.dart | 11 +++----- .../lib/src/html_sanitize_config.dart | 5 +++- sanitize_html/lib/src/node_sanitizer.dart | 2 +- .../lib/src/sane_html_validator.dart | 21 ++++++++++++--- 6 files changed, 41 insertions(+), 26 deletions(-) diff --git a/sanitize_html/lib/src/attribute_policy.dart b/sanitize_html/lib/src/attribute_policy.dart index 174b2692..0fa87a44 100644 --- a/sanitize_html/lib/src/attribute_policy.dart +++ b/sanitize_html/lib/src/attribute_policy.dart @@ -68,7 +68,7 @@ class AttributePolicy { final original = node.className; if (original.isEmpty) return false; - final parts = original.split(RegExp(r'\s+')); + final parts = original.split(HtmlSanitizeConfig.whitespacePattern); node.classes.clear(); for (final c in parts) { diff --git a/sanitize_html/lib/src/css_dangerous_patterns.dart b/sanitize_html/lib/src/css_dangerous_patterns.dart index 7eebd086..7da4d3ab 100644 --- a/sanitize_html/lib/src/css_dangerous_patterns.dart +++ b/sanitize_html/lib/src/css_dangerous_patterns.dart @@ -2,29 +2,29 @@ class CssDangerousPatterns { CssDangerousPatterns._(); // @import statements - static final import = - RegExp(r'@import\s+[^;]+;', caseSensitive: false); + // Note: All regexes are either literal or strictly bounded. + // The @import pattern is syntax-aware and length-bounded + // to avoid excessive backtracking on malformed input. + static final import = RegExp( + "@import\\s+(?:url\\([^)]*\\)|\"[^\"]*\"|'[^']*')[^;]{0,1000};", + caseSensitive: false, + ); // Dangerous protocols - static final javascript = - RegExp(r'javascript\s*:', caseSensitive: false); - static final vbscript = - RegExp(r'vbscript\s*:', caseSensitive: false); + static final javascript = RegExp(r'javascript\s*:', caseSensitive: false); + static final vbscript = RegExp(r'vbscript\s*:', caseSensitive: false); // Legacy / dangerous functions - static final expression = - RegExp(r'expression\s*\(', caseSensitive: false); + static final expression = RegExp(r'expression\s*\(', caseSensitive: false); // SVG / HTML event handlers: onload=, onclick=, onerror=, ... - static final eventHandler = - RegExp(r'on[a-z]+\s*=', caseSensitive: false); + static final eventHandler = RegExp(r'on[a-z]+\s*=', caseSensitive: false); // data:text/* (explicit block) static final dataText = - RegExp(r'data\s*:\s*text\/[a-z0-9.+-]+', caseSensitive: false); + RegExp(r'data\s*:\s*text\/[a-z0-9.+-]+', caseSensitive: false); // Any data:/ - static final dataAny = - RegExp(r'data\s*:\s*([a-z0-9.+-]+)\/([a-z0-9.+-]+)', + static final dataAny = RegExp(r'data\s*:\s*([a-z0-9.+-]+)\/([a-z0-9.+-]+)', caseSensitive: false); } diff --git a/sanitize_html/lib/src/css_sanitizer.dart b/sanitize_html/lib/src/css_sanitizer.dart index 18c976b3..4b2d34b6 100644 --- a/sanitize_html/lib/src/css_sanitizer.dart +++ b/sanitize_html/lib/src/css_sanitizer.dart @@ -12,7 +12,7 @@ class CssSanitizer { String inside = clean.substring(4, clean.length - 1).trim(); // Remove comments to avoid bypass - inside = inside.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), '').trim(); + inside = inside.replaceAll(HtmlSanitizeConfig.cssCommentPattern, '').trim(); // Strip optional wrapping quotes: url("...") or url('...') if ((inside.startsWith('"') && inside.endsWith('"')) || @@ -135,7 +135,7 @@ class CssSanitizer { /// Normalize whitespace: "0px 20px\n " → "0px 20px" static String _normalizeWhitespace(String value) => - value.replaceAll(RegExp(r'\s+'), ' ').trim(); + value.replaceAll(HtmlSanitizeConfig.whitespacePattern, ' ').trim(); /// Add px to pure integer width/height values: /// height: 10 → height: 10px @@ -342,7 +342,7 @@ class CssSanitizer { // Block non-image data:* (preserve only data:image/*) out = out.replaceAllMapped( CssDangerousPatterns.dataAny, - (m) { + (m) { final type = (m.group(1) ?? '').toLowerCase().trim(); return type == 'image' ? m.group(0)! : ''; }, @@ -380,10 +380,7 @@ class CssSanitizer { css = _stripTopLevelCssComments(css).trim(); // Strip @import statements safely - css = css.replaceAll( - RegExp(r'@import\s+[^;]+;', caseSensitive: false), - '', - ); + css = css.replaceAll(CssDangerousPatterns.import, ''); final buffer = StringBuffer(); diff --git a/sanitize_html/lib/src/html_sanitize_config.dart b/sanitize_html/lib/src/html_sanitize_config.dart index 9eaa9cd0..9bc40611 100644 --- a/sanitize_html/lib/src/html_sanitize_config.dart +++ b/sanitize_html/lib/src/html_sanitize_config.dart @@ -444,7 +444,10 @@ class HtmlSanitizeConfig { 'revert', }; - static final RegExp cssCommentPattern = RegExp(r'/\*.*?\*/', dotAll: true); + // Note: Regexes are either anchored, literal, or strictly bounded. + // cssCommentPattern is length-bounded to avoid excessive backtracking + // on malformed or attacker-controlled CSS input. + static final RegExp cssCommentPattern = RegExp(r'/\*[\s\S]{0,2000}?\*/'); static final RegExp unicodeEscapeReg = RegExp(r'\\[0-9a-f]{2}', caseSensitive: false); diff --git a/sanitize_html/lib/src/node_sanitizer.dart b/sanitize_html/lib/src/node_sanitizer.dart index 6a4d00fa..40f280ea 100644 --- a/sanitize_html/lib/src/node_sanitizer.dart +++ b/sanitize_html/lib/src/node_sanitizer.dart @@ -191,7 +191,7 @@ class NodeSanitizer { // strip CSS-style comments (to block ja/*x*/vascript:) normalized = - normalized.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); + normalized.replaceAll(HtmlSanitizeConfig.cssCommentPattern, ''); lower = normalized.toLowerCase().trim(); // block dangerous schemes diff --git a/sanitize_html/lib/src/sane_html_validator.dart b/sanitize_html/lib/src/sane_html_validator.dart index a4d5b774..bc47c1c0 100644 --- a/sanitize_html/lib/src/sane_html_validator.dart +++ b/sanitize_html/lib/src/sane_html_validator.dart @@ -101,11 +101,26 @@ class SaneHtmlValidator { return buffer.toString(); } + // Detect nested CSS blocks using a single-pass scan to avoid + // regex backtracking on malformed or attacker-controlled input. bool containsNestedCss(String css) { - // Detect `{ ... {` pattern outside comments - return RegExp(r'\{[^}]*\{', dotAll: true).hasMatch(css); - } + bool insideBlock = false; + + for (var i = 0; i < css.length; i++) { + final c = css.codeUnitAt(i); + + if (c == 0x7B /* { */) { + if (insideBlock) { + return true; // Found nested { + } + insideBlock = true; + } else if (c == 0x7D /* } */) { + insideBlock = false; + } + } + return false; + } /// Extracts raw CSS text from all