diff --git a/sanitize_html/CHANGELOG.md b/sanitize_html/CHANGELOG.md index f3d7fb05..f4243c57 100644 --- a/sanitize_html/CHANGELOG.md +++ b/sanitize_html/CHANGELOG.md @@ -1,3 +1,14 @@ +## v3.0.2 +* fix(sanitizer): tighten unicodeEscapeReg to require 3+ consecutive \XX sequences + +## v3.0.1 +* Preserve and safely sanitize internal CSS +* Preserve nested CSS with token-level sanitization +* Mitigate potential ReDoS in regex patterns + +## 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/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/docs/html_sanitization_engine.md b/sanitize_html/docs/html_sanitization_engine.md new file mode 100644 index 00000000..9cb04e75 --- /dev/null +++ b/sanitize_html/docs/html_sanitization_engine.md @@ -0,0 +1,346 @@ +# 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 `'; + }).join('\n'); + } + + String _escapeAttribute(String value) { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + } + + /// Sanitizes HTML: + /// - strips comments + /// - parses DOM + /// - applies NodeSanitizer to + /// - returns sanitized inner HTML + String sanitize(String html) { + if (html.isEmpty) return ''; + + 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 css = s.css; + // Nested CSS → PRESERVE + if (containsNestedCss(css)) { + return ExtractedStyle( + css: CssSanitizer.stripDangerousTokens(css), + media: s.media, + ); + } + + // Flat CSS → SANITIZE + final sanitized = CssSanitizer.sanitizeStylesheet(css); + return ExtractedStyle(css: sanitized, media: s.media); + }) + .where((s) => s.css.isNotEmpty) + .toList(); + + // Rebuild HTML + final styleBlock = rebuildStyleBlock(safeStyles); + + final output = body.innerHtml.trim(); + 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, + ]; + + final result = pieces.join('\n').trim(); + log(''); + return result; } } diff --git a/sanitize_html/lib/src/url_validators.dart b/sanitize_html/lib/src/url_validators.dart new file mode 100644 index 00000000..e3454a93 --- /dev/null +++ b/sanitize_html/lib/src/url_validators.dart @@ -0,0 +1,67 @@ +import 'package:sanitize_html/src/html_sanitize_config.dart'; + +class UrlValidators { + /// Common internal URI validator used by validLink() and validUrl(). + static Uri? _tryParse(String input) { + if (input.isEmpty) return null; + + try { + return Uri.parse(input); + } catch (_) { + return null; + } + } + + /// Validates a hyperlink () + /// + /// Allowed: + /// - http + /// - https + /// - mailto + /// - URLs without scheme (relative links) + static bool validLink(String url) { + final uri = _tryParse(url); + if (uri == null) return false; + + // Block protocol-relative URLs + if (!uri.hasScheme && uri.host.isNotEmpty) return false; + + return uri.isScheme('https') || + uri.isScheme('http') || + uri.isScheme('mailto') || + !uri.hasScheme; + } + + /// Validates a general URL (non-mailto) + /// + /// Allowed: + /// - http + /// - https + /// - URLs without scheme + static bool validUrl(String url) { + final uri = _tryParse(url); + if (uri == null) return false; + + return uri.isScheme('https') || uri.isScheme('http') || !uri.hasScheme; + } + + /// Check base64 image header + static bool validBase64Image(String v) { + return HtmlSanitizeConfig.base64ImageRegex.hasMatch(v); + } + + /// Check cid:xxx inline attachments + static bool validCIDImage(String cid) { + return cid.startsWith('cid:'); + } + + /// Unified image source validator for + /// + /// Allowed: + /// - http/https or no-scheme URLs + /// - data:image/*;base64 + /// - cid:xxxxx + static bool validImageSource(String url) { + return validUrl(url) || validBase64Image(url) || validCIDImage(url); + } +} diff --git a/sanitize_html/pubspec.yaml b/sanitize_html/pubspec.yaml index 3b176b78..e7b92dc4 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.2 description: >- Function for sanitizing HTML to prevent XSS by restrict elements and attributes to a safe subset of allowed values. diff --git a/sanitize_html/test/attribute_policy_test.dart b/sanitize_html/test/attribute_policy_test.dart new file mode 100644 index 00000000..f8498b5b --- /dev/null +++ b/sanitize_html/test/attribute_policy_test.dart @@ -0,0 +1,54 @@ +import 'package:html/dom.dart'; +import 'package:sanitize_html/src/attribute_policy.dart'; +import 'package:test/test.dart'; + +void main() { + group('AttributePolicy', () { + test('id accepted only when safe', () { + final elem = Element.tag('div'); + + elem.attributes['id'] = 'validId123'; + expect( + AttributePolicy.sanitizeAttribute(elem, 'id', 'validId123'), + true, + ); + + elem.attributes['id'] = '-invalid!'; + expect( + AttributePolicy.sanitizeAttribute(elem, 'id', '-invalid!'), + false, + ); + }); + + test('class filters invalid class names', () { + final elem = Element.tag('span') + ..classes.addAll(['ok', '9invalid', '_alsoBad']); + + final result = + AttributePolicy.sanitizeAttribute(elem, 'class', elem.className); + + expect(result, true); + expect(elem.classes, ['ok']); + }); + + test('style sanitized correctly', () { + final elem = Element.tag('div'); + elem.attributes['style'] = 'color: red; position: absolute;'; + + final result = AttributePolicy.sanitizeAttribute( + elem, 'style', elem.attributes['style']!); + + expect(result, true); + expect(elem.attributes['style'], 'color: red'); + }); + + test('A[href] validated correctly', () { + final a = Element.tag('a'); + a.attributes['href'] = 'javascript:alert(1)'; + + final ok = + AttributePolicy.sanitizeAttribute(a, 'href', 'javascript:alert(1)'); + expect(ok, false); + }); + }); +} diff --git a/sanitize_html/test/css_sanitizer_test.dart b/sanitize_html/test/css_sanitizer_test.dart new file mode 100644 index 00000000..6405aee3 --- /dev/null +++ b/sanitize_html/test/css_sanitizer_test.dart @@ -0,0 +1,848 @@ +import 'package:sanitize_html/src/css_sanitizer.dart'; +import 'package:test/test.dart'; + +void main() { + group('CssSanitizer – inline CSS', () { + test('allows safe properties', () { + final result = + CssSanitizer.sanitizeInline('color: red; font-size: 12px;'); + expect(result, 'color: red; font-size: 12px'); + }); + + test('removes unsafe properties', () { + final result = CssSanitizer.sanitizeInline( + 'color: red; position: absolute; top: 10'); + expect(result, 'color: red'); + }); + + test('removes javascript urls', () { + final result = + CssSanitizer.sanitizeInline('background: url(javascript:alert(1));'); + + expect(result, '', + reason: + 'background is not in allowedCssProperties → removed entirely'); + }); + + test('preserves line-height', () { + final result = CssSanitizer.sanitizeInline('line-height: 1;'); + expect(result, 'line-height: 1'); + }); + + test('adds px to unitless height/width', () { + final result = CssSanitizer.sanitizeInline('height: 10; width: 20;'); + expect(result, 'height: 10px; width: 20px'); + }); + + test('does NOT modify width/height with existing units', () { + final result = CssSanitizer.sanitizeInline('height: 5em; width: 20rem;'); + expect(result, 'height: 5em; width: 20rem'); + }); + + test('normalizes padding whitespace', () { + final result = CssSanitizer.sanitizeInline('padding: 0px 20px;'); + expect(result, 'padding: 0px 20px'); + }); + + test('normalizes border whitespace', () { + final result = + CssSanitizer.sanitizeInline('border: 1px solid #000;'); + expect(result, 'border: 1px solid #000'); + }); + + test('removes forbidden CSS keywords', () { + final result = + CssSanitizer.sanitizeInline('width: 100; behavior: url(thing.htc);'); + expect(result, 'width: 100px'); + }); + + test('removes unknown CSS properties', () { + final result = + CssSanitizer.sanitizeInline('color: red; unknown-prop: 123;'); + expect(result, 'color: red'); + }); + + test('removes invalid declarations (no colon)', () { + final result = CssSanitizer.sanitizeInline('color red; width:10;'); + expect(result, 'width: 10px'); + }); + + test('handles empty style input', () { + expect(CssSanitizer.sanitizeInline(' '), ''); + expect(CssSanitizer.sanitizeInline(';;;'), ''); + }); + + test('preserves base64 image values', () { + final style = + 'background-image: url(data:image/png;base64,AAABBBCCC123==);'; + + final result = CssSanitizer.sanitizeInline(style); + + expect( + result, + 'background-image: url(data:image/png;base64,AAABBBCCC123==)', + ); + }); + + test('drops dangerous expressions', () { + final result = + CssSanitizer.sanitizeInline('width: expression(alert(1));'); + expect(result, ''); + }); + + test('drops unicode JS payload', () { + final result = CssSanitizer.sanitizeInline( + r'background: url(\6a\61\76\61script:evil);'); + expect(result, ''); + }); + + test('normalizes multiple declarations', () { + final result = CssSanitizer.sanitizeInline( + 'color: blue; height: 10; padding: 0px 20px;'); + expect(result, 'color: blue; height: 10px; padding: 0px 20px'); + }); + + test('keeps only allowed properties with correct order', () { + final result = CssSanitizer.sanitizeInline( + 'padding:10px; unknown:1; color:red; border: 1px solid black;'); + expect(result, 'padding: 10px; color: red; border: 1px solid black'); + }); + + test('keeps safe url() in background-image', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(/images/header.jpg); padding: 20px;'); + + expect( + result.contains('background-image: url(/images/header.jpg)'), true); + expect(result.contains('padding: 20px'), true); + }); + + test('keeps https url() in background-image', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(https://example.com/bg.png);'); + + expect(result, 'background-image: url(https://example.com/bg.png)'); + }); + + test('allows data:image/* base64 url() in background-image', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA);', + ); + + expect( + result, + 'background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA)', + ); + }); + + test('removes javascript: url() but keeps safe properties', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(javascript:evil); color: red;'); + + expect(result, 'color: red'); + }); + + test('keeps opacity property', () { + final result = CssSanitizer.sanitizeInline('opacity: 0.9;'); + expect(result, 'opacity: 0.9'); + }); + + test('keeps box-shadow property', () { + final result = + CssSanitizer.sanitizeInline('box-shadow: 0 2px 5px rgba(0,0,0,0.2);'); + expect(result, 'box-shadow: 0 2px 5px rgba(0,0,0,0.2)'); + }); + + test('keeps text-shadow property', () { + final result = + CssSanitizer.sanitizeInline('text-shadow: 1px 1px 2px #333;'); + expect(result, 'text-shadow: 1px 1px 2px #333'); + }); + + test('keeps display:flex', () { + final result = CssSanitizer.sanitizeInline('display: flex;'); + expect(result, 'display: flex'); + }); + + test('keeps justify-content for flexbox', () { + final result = CssSanitizer.sanitizeInline( + 'display: flex; justify-content: space-between;'); + + expect(result.contains('display: flex'), true); + expect(result.contains('justify-content: space-between'), true); + }); + + test('keeps align-items for flexbox', () { + final result = + CssSanitizer.sanitizeInline('display: flex; align-items: center;'); + + expect(result.contains('align-items: center'), true); + }); + + test('keeps flex shorthand', () { + final result = CssSanitizer.sanitizeInline('flex: 1;'); + expect(result, 'flex: 1'); + }); + + test('keeps safe background-image', () { + final result = + CssSanitizer.sanitizeInline('background-image: url(/img/bg.png);'); + + expect(result, 'background-image: url(/img/bg.png)'); + }); + + test('removes unsafe background-image with javascript url()', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(javascript:evil); opacity: 1;'); + + expect(result, 'opacity: 1'); + }); + }); + + group('CssSanitizer – stylesheet', () { + test('removes comments & @rules', () { + final css = ''' + /* comment */ + @media screen { color: red; } + p { color: blue; position: absolute; } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + expect(result, 'p { color: blue }'); + }); + + test('handles multiple blocks', () { + final css = ''' + p { color: red; } + div { height: 10; width: 20; } + span { position: absolute; } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + + expect(result.contains('p { color: red }'), true); + expect(result.contains('div { height: 10px; width: 20px }'), true); + expect(result.contains('span'), false); + }); + + test('ignores @keyframes, @supports, @font-face rules', () { + final css = ''' + @keyframes myanim { 0% { opacity:0 } } + @supports(display:flex) { div { color: blue } } + @font-face { font-family:x; src:url(a) } + p { color: green } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + + expect(result, 'p { color: green }'); + }); + + test('ignores invalid blocks', () { + final css = ''' + invalidblock + p color: red } + { missingselector: 1 } + span { color: red; } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + + // only valid block should remain + expect(result, 'span { color: red }'); + }); + + test('sanitizes multi-line declarations inside block', () { + final css = ''' + p { + padding: 0px + 20px; + border: 1px + solid + #000; + } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + + expect(result.contains('padding: 0px 20px'), true); + expect(result.contains('border: 1px solid #000'), true); + }); + + test('drops entire block if all properties unsafe', () { + final css = ''' + p { position:absolute; top:0 } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + + expect(result.isEmpty, true); + }); + + test('keeps selector but sanitizes properties', () { + final css = ''' + div { color: red; height: 5 } + '''; + + final result = CssSanitizer.sanitizeStylesheet(css); + + expect(result, 'div { color: red; height: 5px }'); + }); + + test('trims output and removes trailing braces', () { + final css = ''' + p { color: blue } + '''; + + expect(CssSanitizer.sanitizeStylesheet(css), 'p { color: blue }'); + }); + }); + + group( + 'CssSanitizer.sanitizeInline – protocol-relative URL (//domain) blocking', + () { + test('blocks protocol-relative URL in background-image', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(//evil.com/a.png);', + ); + + expect( + result.contains('background-image'), + false, + reason: 'background-image with protocol-relative URL must be removed', + ); + }); + + test('blocks protocol-relative URL with whitespace', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url( //evil.com/a.png ); color: red;', + ); + + expect(result.contains('color: red'), true); + expect(result.contains('background-image'), false); + }); + + test('blocks protocol-relative URL without url()', () { + final result = CssSanitizer.sanitizeInline( + 'background: //evil.com/bg.jpg; font-size: 14px;', + ); + + expect(result.contains('background'), false); + expect(result.contains('font-size: 14px'), true); + }); + + test('blocks mixed-case protocol-relative URL', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(//EVIL.com/a.png);', + ); + + expect( + result.contains('background-image'), + false, + reason: 'mixed-case //EVIL.com must still be blocked', + ); + }); + + test('does not block safe http URL', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(http://example.com/x.png);', + ); + + expect(result, 'background-image: url(http://example.com/x.png)'); + }); + + test('does not block safe https URL', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(https://example.com/x.png);', + ); + + expect(result, 'background-image: url(https://example.com/x.png)'); + }); + + test('does not block data:image/png;base64 (safe base64)', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(data:image/png;base64,AAA);', + ); + + expect(result, 'background-image: url(data:image/png;base64,AAA)'); + }); + + test('blocks protocol-relative URL when inside shorthand background', () { + final result = CssSanitizer.sanitizeInline( + 'background: url(//evil.com/x.png) no-repeat center;', + ); + + expect( + result.contains('background'), + false, + reason: 'Any background containing // must be removed', + ); + }); + + test('multiple declarations – only unsafe is removed', () { + final result = CssSanitizer.sanitizeInline( + 'padding: 10px; background-image: url(//evil.com/a.png); margin: 5px;', + ); + + expect(result.contains('padding: 10px'), true); + expect(result.contains('margin: 5px'), true); + expect( + result.contains('background-image'), + false, + reason: 'unsafe declaration should be removed without affecting others', + ); + }); + + test('protocol-relative URL with comments (obfuscation attempt)', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(/*x*/ //evil.com/hack.png);', + ); + + expect( + result.contains('background-image'), + false, + reason: 'comments should not allow bypass of // rule', + ); + }); + + test('protocol-relative URL inside quotes', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url("//evil.com/a.png");', + ); + + expect( + result.contains('background-image'), + false, + reason: 'quoted protocol-relative URL must also be blocked', + ); + }); + + test('protocol-relative URL with escaped slashes', () { + final result = CssSanitizer.sanitizeInline( + r'background-image: url(\/\//evil.com/a.png);', + ); + + expect(result.contains('background-image'), false); + }); + + test('blocks Unicode-escaped slashes', () { + final result = CssSanitizer.sanitizeInline( + r'background-image: url(\u002F\u002Fevil.com);', + ); + expect(result.contains('background-image'), false); + }); + + test('blocks percent-encoded protocol-relative', () { + final result = CssSanitizer.sanitizeInline( + 'background-image: url(%2F%2Fevil.com);', + ); + expect(result.contains('background-image'), false); + }); + + test('blocks obfuscated javascript url inside multi-token background', () { + final result = CssSanitizer.sanitizeInline( + 'background: red url(java/**/script:alert(1)) no-repeat;', + ); + + expect(result.contains('background'), false); + }); + + test('keeps safe https url inside multi-token background', () { + final result = CssSanitizer.sanitizeInline( + 'background: #fff url(https://example.com/bg.png) no-repeat;', + ); + + expect( + result, 'background: #fff url(https://example.com/bg.png) no-repeat'); + }); + + test('blocks malformed url without closing parenthesis', () { + final result = CssSanitizer.sanitizeInline( + 'background: url(https://example.com/bg.png;', + ); + + expect(result.contains('background'), false); + }); + }); + + group('CssSanitizer.sanitizeInline – overflow rules', () { + test('keeps overflow: hidden when allowed', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: hidden; white-space: nowrap;', + ); + + expect(result.contains('overflow: hidden'), true); + expect(result.contains('white-space: nowrap'), true); + }); + + test('keeps overflow-x: scroll when allowed', () { + final result = CssSanitizer.sanitizeInline( + 'overflow-x: scroll; color: red;', + ); + + expect(result.contains('overflow-x: scroll'), true); + expect(result.contains('color: red'), true); + }); + + test('keeps overflow-y: auto when allowed', () { + final result = CssSanitizer.sanitizeInline( + 'overflow-y: auto; font-size: 14px;', + ); + + expect(result.contains('overflow-y: auto'), true); + expect(result.contains('font-size: 14px'), true); + }); + + test('removes overflow with disallowed value', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: visiblex; color: blue;', + ); + + expect(result.contains('overflow'), false, + reason: 'Only visible/hidden/scroll/auto allowed'); + expect(result.contains('color: blue'), true); + }); + + test('removes overflow that contains expression() attack', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: expression(alert(1));', + ); + + expect(result.contains('overflow'), false); + }); + + test('removes overflow with javascript: url attack', () { + final result = CssSanitizer.sanitizeInline( + 'overflow-x: url(javascript:alert(1));', + ); + + expect(result.contains('overflow-x'), false); + }); + + test('removes overflow with protocol-relative URL //evil.com', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: //evil.com; text-align: center;', + ); + + expect(result.contains('overflow'), false); + expect(result.contains('text-align: center'), true); + }); + + test('keeps valid ellipsis chain overflow + white-space + text-overflow', + () { + final result = CssSanitizer.sanitizeInline( + 'overflow: hidden; white-space: nowrap; text-overflow: ellipsis;', + ); + + expect(result.contains('overflow: hidden'), true); + expect(result.contains('white-space: nowrap'), true); + expect(result.contains('text-overflow: ellipsis'), true); + }); + + test('keeps overflow: auto when multiple declarations exist', () { + final result = CssSanitizer.sanitizeInline( + 'color: red; overflow: auto; padding: 10px;', + ); + + expect(result.contains('overflow: auto'), true); + expect(result.contains('color: red'), true); + expect(result.contains('padding: 10px'), true); + }); + + test('normalizes units before applying overflow rule', () { + final result = CssSanitizer.sanitizeInline( + 'width: 100px; overflow: hidden; height: 2em;', + ); + + expect(result.contains('width: 100px'), true); + expect(result.contains('height: 2em'), true); + expect(result.contains('overflow: hidden'), true); + }); + + test('keeps overflow with normalized sibling properties', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: auto; max-width: 50%; padding: 10px;', + ); + + expect(result.contains('overflow: auto'), true); + expect(result.contains('max-width: 50%'), true); + expect(result.contains('padding: 10px'), true); + }); + + test('property order does not affect sanitize result', () { + final resultA = CssSanitizer.sanitizeInline( + 'overflow: hidden; white-space: nowrap; text-overflow: ellipsis;', + ); + + final resultB = CssSanitizer.sanitizeInline( + 'text-overflow: ellipsis; overflow: hidden; white-space: nowrap;', + ); + + expect(resultA.contains('overflow: hidden'), true); + expect(resultA.contains('white-space: nowrap'), true); + expect(resultA.contains('text-overflow: ellipsis'), true); + + expect(resultB.contains('overflow: hidden'), true); + expect(resultB.contains('white-space: nowrap'), true); + expect(resultB.contains('text-overflow: ellipsis'), true); + }); + + test('reordering with mix of safe and unsafe properties', () { + final out = CssSanitizer.sanitizeInline( + 'color: red; overflow: hidden; expression: test; padding: 4px;', + ); + + expect(out.contains('expression'), false); + expect(out.contains('color: red'), true); + expect(out.contains('padding: 4px'), true); + expect(out.contains('overflow: hidden'), true); + }); + + test('removes overflow if value contains comment obfuscation', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: /*evil*/ hidden;', + ); + expect(result.contains('overflow'), false, + reason: 'Comment obfuscation must not bypass overflow value rule'); + }); + + test('removes overflow if comment splits value (hidden)', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: h/*x*/idden;', + ); + expect(result.contains('overflow'), false); + }); + + test('blocks comment-wrapped value overflow: /*x*/auto', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: /*x*/auto;', + ); + expect(result.contains('overflow'), false); + }); + + test('blocks hidden with trailing comment overflow: hidden/*x*/', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: hidden/*x*/;', + ); + expect(result.contains('overflow'), false); + }); + + test('does not allow comment to bypass protocol-relative // attack', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: /*x*/ //evil.com;', + ); + expect(result.contains('overflow'), false); + }); + + test('blocks unicode escaped "hidden" (h\\0069dden)', () { + final result = CssSanitizer.sanitizeInline( + r'overflow: h\0069dden;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks unicode escaped "auto" (\\0061uto)', () { + final result = CssSanitizer.sanitizeInline( + r'overflow: \0061uto;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks multi-digit unicode encoding (h\\000069dden)', () { + final result = CssSanitizer.sanitizeInline( + r'overflow: h\000069dden;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks hidden with zero-width space injection', () { + final zwsp = '\u200B'; + final result = CssSanitizer.sanitizeInline( + 'overflow: h${zwsp}idden;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks hidden with mixed zero-width chars', () { + final zw1 = '\u200B'; + final zw2 = '\u200C'; + final zw3 = '\u200D'; + + final result = CssSanitizer.sanitizeInline( + 'overflow: h${zw1}i${zw2}d${zw3}den;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks hidden split across multiple comments h/*x*//*y*/idden', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: h/*x*//*y*/idden;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks comment before + after + inside "auto"', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: /*a*/a/*b*/u/*c*/t/*d*/o/*e*/;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks hidden using character reference (hidden)', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: hidden;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks auto using entity (auto)', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: auto;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks scroll using numeric entities (scroll)', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: scroll;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks hybrid attack: h\\0069d/*x*/d\\0065n + zero-width', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: h\\0069d/*x*/d\\0065n\u200B;', + ); + + expect(result.contains('overflow'), false); + }); + + test('blocks //evil.com hidden behind unicode or comment', () { + final result = CssSanitizer.sanitizeInline( + r'overflow: /*x*/ \002F\002Fevil.com;', + ); + + expect(result.contains('overflow'), false); + }); + + test('allows case-insensitive overflow: HIDDEN', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: HIDDEN; color: red;', + ); + + expect(result.contains('overflow: HIDDEN'), true); + expect(result.contains('color: red'), true); + }); + + test('allows case-insensitive overflow: Hidden', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: Hidden; padding: 5px;', + ); + + expect(result.contains('overflow: Hidden'), true); + expect(result.contains('padding: 5px'), true); + }); + + test('allows case-insensitive overflow-x: AUTO', () { + final result = CssSanitizer.sanitizeInline( + 'overflow-x: AUTO; margin: 10px;', + ); + + expect(result.contains('overflow-x: AUTO'), true); + expect(result.contains('margin: 10px'), true); + }); + + test('allows case-insensitive overflow-y: Scroll', () { + final result = CssSanitizer.sanitizeInline( + 'overflow-y: Scroll; width: 100px;', + ); + + expect(result.contains('overflow-y: Scroll'), true); + expect(result.contains('width: 100px'), true); + }); + + test('allows case-insensitive overflow: VISIBLE', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: VISIBLE; height: 50px;', + ); + + expect(result.contains('overflow: VISIBLE'), true); + expect(result.contains('height: 50px'), true); + }); + + test('blocks invalid overflow value regardless of case', () { + final result = CssSanitizer.sanitizeInline( + 'overflow: INVALID; color: blue;', + ); + + expect(result.contains('overflow'), false); + expect(result.contains('color: blue'), true); + }); + + test('keeps original case for inherit', () { + final result = CssSanitizer.sanitizeInline('overflow: inherit;'); + expect(result, 'overflow: inherit'); + }); + + test('keeps original case for InHeRiT', () { + final result = CssSanitizer.sanitizeInline('overflow: InHeRiT;'); + expect(result, 'overflow: InHeRiT'); + }); + + test('keeps original case for INITIAL', () { + final result = CssSanitizer.sanitizeInline('overflow: INITIAL;'); + expect(result, 'overflow: INITIAL'); + }); + + test('keeps original case for UnSeT', () { + final result = CssSanitizer.sanitizeInline('overflow: UnSeT;'); + expect(result, 'overflow: UnSeT'); + }); + + test('keeps original case for ReVeRt', () { + final result = CssSanitizer.sanitizeInline('overflow: ReVeRt;'); + expect(result, 'overflow: ReVeRt'); + }); + + test('blocks invalid "none"', () { + final result = CssSanitizer.sanitizeInline('overflow: none;'); + expect(result.contains('overflow'), false); + }); + + test('blocks random value', () { + final result = CssSanitizer.sanitizeInline('overflow: abcxyz;'); + expect(result.contains('overflow'), false); + }); + + test('blocks inherit obfuscated with comments', () { + final result = CssSanitizer.sanitizeInline('overflow: in/**/her/**/it;'); + expect(result.contains('overflow'), false); + }); + + test('blocks inherit + js attempt', () { + final result = + CssSanitizer.sanitizeInline('overflow: inherit javascript:alert(1)'); + expect(result.contains('overflow'), false); + }); + + test('blocks ReVeRt + garbage', () { + final result = CssSanitizer.sanitizeInline('overflow: ReVeRt xxx;'); + expect(result.contains('overflow'), false); + }); + + test('keeps casing but trims spaces', () { + final result = CssSanitizer.sanitizeInline(' overflow : INITIAL ; '); + expect(result, 'overflow: INITIAL'); + }); + }); +} diff --git a/sanitize_html/test/html_sanitize_config_test.dart b/sanitize_html/test/html_sanitize_config_test.dart new file mode 100644 index 00000000..ab86d9bb --- /dev/null +++ b/sanitize_html/test/html_sanitize_config_test.dart @@ -0,0 +1,75 @@ +import 'package:sanitize_html/src/html_sanitize_config.dart'; +import 'package:test/test.dart'; + +void main() { + group('HtmlSanitizeConfig', () { + test('allowedElements should not contain forbiddenTags', () { + for (final tag in HtmlSanitizeConfig.forbiddenTags) { + expect( + HtmlSanitizeConfig.allowedElements.contains(tag), + false, + reason: 'Tag $tag must not appear in allowedElements', + ); + } + }); + + test('safe id regex matches correctly', () { + expect(HtmlSanitizeConfig.safeIdPattern.hasMatch('abc123'), true); + expect(HtmlSanitizeConfig.safeIdPattern.hasMatch('-invalid'), false); + expect(HtmlSanitizeConfig.safeIdPattern.hasMatch('A_valid.id'), true); + }); + + test('safe class regex matches correctly', () { + expect(HtmlSanitizeConfig.safeClassPattern.hasMatch('hello'), true); + expect(HtmlSanitizeConfig.safeClassPattern.hasMatch('9abc'), false); + expect(HtmlSanitizeConfig.safeClassPattern.hasMatch('class_name'), true); + }); + + group('unicodeEscapeReg', () { + // The regex requires 3+ consecutive \XX sequences so that single + // backslash-hex pairs in plain text (e.g. PHP namespace separators) + // are not false-positives, while meaningful obfuscation is still caught. + + test('does NOT match a single \\XX sequence', () { + // e.g. \DA from a PHP namespace like \DAV + expect(HtmlSanitizeConfig.unicodeEscapeReg.hasMatch(r'\DA'), false); + expect(HtmlSanitizeConfig.unicodeEscapeReg.hasMatch(r'\ab'), false); + expect(HtmlSanitizeConfig.unicodeEscapeReg.hasMatch(r'\FF'), false); + }); + + test('does NOT match two consecutive \\XX sequences', () { + expect(HtmlSanitizeConfig.unicodeEscapeReg.hasMatch(r'\6a\73'), false); + }); + + test('DOES match three or more consecutive \\XX sequences', () { + // \73\72\63 encodes "src" — still caught + expect(HtmlSanitizeConfig.unicodeEscapeReg.hasMatch(r'\73\72\63'), true); + // \6a\61\76 encodes "jav" — prefix of "javascript" + expect(HtmlSanitizeConfig.unicodeEscapeReg.hasMatch(r'\6a\61\76'), true); + }); + + test('DOES match full "javascript" encoded as \\XX sequences', () { + // \6a\61\76\61\73\63\72\69\70\74 = "javascript" + expect( + HtmlSanitizeConfig.unicodeEscapeReg + .hasMatch(r'\6a\61\76\61\73\63\72\69\70\74'), + true, + ); + }); + + test('does NOT match PHP-style backslash namespace separators', () { + // Regression: \Sabre\DAV\Exception had \DA matching the old single-pair regex. + expect( + HtmlSanitizeConfig.unicodeEscapeReg + .hasMatch(r'\Sabre\DAV\Exception\Forbidden'), + false, + ); + expect( + HtmlSanitizeConfig.unicodeEscapeReg + .hasMatch(r'\App\DB\Exception\AuthFailed'), + false, + ); + }); + }); + }); +} diff --git a/sanitize_html/test/node_sanitizer_test.dart b/sanitize_html/test/node_sanitizer_test.dart new file mode 100644 index 00000000..efdf7386 --- /dev/null +++ b/sanitize_html/test/node_sanitizer_test.dart @@ -0,0 +1,109 @@ +import 'package:html/parser.dart'; +import 'package:sanitize_html/src/node_sanitizer.dart'; +import 'package:test/test.dart'; + +void main() { + group('NodeSanitizer', () { + late NodeSanitizer sanitizer; + + setUp(() { + sanitizer = NodeSanitizer( + allowId: null, + allowClass: null, + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ); + }); + + test('removes forbidden tags', () { + final doc = parse('

Hello

'); + sanitizer.sanitize(doc.body!, allowUnwrap: false); + + expect(doc.body!.innerHtml.trim(), '

Hello

'); + }); + + test('sanitizes STYLE inside (default html parser behavior)', () { + final doc = parse(''); + + expect(doc.head!.innerHtml.contains('position'), true); + + sanitizer.sanitize(doc.head!, allowUnwrap: false); + + expect( + doc.head!.innerHtml.trim(), + '', + ); + }); + + test('sanitizes STYLE inside fragment (email HTML case)', () { + final doc = parse( + '', + ); + + if (doc.head != null) sanitizer.sanitize(doc.head!, allowUnwrap: false); + + expect( + doc.head!.innerHtml.trim(), + '', + ); + }); + + test('sanitizes attributes correctly', () { + final doc = parse( + '

Hi

', + ); + + sanitizer.sanitize(doc.body!, allowUnwrap: false); + + expect(doc.body!.innerHtml, '

Hi

'); + }); + + test('sanitize document head + body together', () { + final doc = parse(''' + +

Hello

+ '''); + + if (doc.head != null) sanitizer.sanitize(doc.head!, allowUnwrap: false); + if (doc.body != null) sanitizer.sanitize(doc.body!, allowUnwrap: false); + + expect( + doc.head!.innerHtml.trim(), + '', + ); + + expect( + doc.body!.innerHtml.trim(), + '

Hello

', + ); + }); + + group('_shouldStripText – backslash-hex false-positive regression', () { + // Before the unicodeEscapeReg tightening, any text node containing + // \XX (backslash + two hex digits) was stripped. This caused plain-text + // emails with PHP namespace separators like \Sabre\DAV to go blank. + + test('preserves text node with single \\XX sequence (PHP namespace separator)', () { + final doc = parse('

throw new \\Sabre\\DAV\\Exception\\Forbidden()

'); + sanitizer.sanitize(doc.body!, allowUnwrap: false); + expect(doc.body!.innerHtml, contains('Forbidden')); + }); + + test('preserves text node with \\App\\DB\\Exception\\AuthFailed pattern', () { + final doc = parse('

throw new \\App\\DB\\Exception\\AuthFailed("denied")

'); + sanitizer.sanitize(doc.body!, allowUnwrap: false); + expect(doc.body!.innerHtml, contains('AuthFailed')); + }); + + test('still strips text node with many consecutive \\XX sequences (encoded "javascript")', () { + // \6a\61\76\61\73\63\72\69\70\74 = "javascript" — must still be caught. + final doc = parse( + '

href=\\6a\\61\\76\\61\\73\\63\\72\\69\\70\\74:alert(1)

', + ); + sanitizer.sanitize(doc.body!, allowUnwrap: false); + expect(doc.body!.innerHtml, isNot(contains('\\6a\\61\\76'))); + }); + }); + }); +} diff --git a/sanitize_html/test/sane_html_validator_test.dart b/sanitize_html/test/sane_html_validator_test.dart new file mode 100644 index 00000000..33595126 --- /dev/null +++ b/sanitize_html/test/sane_html_validator_test.dart @@ -0,0 +1,2519 @@ +import 'package:sanitize_html/src/sane_html_validator.dart'; +import 'package:test/test.dart'; + +void main() { + group('SaneHtmlValidator.sanitize', () { + group('Base behavior', () { + late SaneHtmlValidator validator; + + setUp(() { + validator = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: (_) => ['nofollow'], + allowAttributes: null, + allowTags: null, + ); + }); + + group('Forbidden tags', () { + test('removes script / iframe / object / embed / applet', () { + final out = validator.sanitize(''' +
+ + + + + +

Valid

+
+ '''); + + expect(out.contains(' + +

Visible

+ + '''); + + expect(out.contains('

Hello

').trim(), + '

Hello

', + ); + }); + }); + + group('Forbidden form/interactive tags', () { + test('removes interactive tags: input/button/select/textarea', () { + final out = validator.sanitize(''' +
+ + + + +

Hello

+
+ '''); + + expect(out.contains(' + +
+ + '''); + + expect(out.contains('onclick'), false); + expect(out.contains('onmouseover'), false); + expect(out.contains('onload'), false); + expect(out.contains('onfocus'), false); + }); + + test('removes forbidden attributes nested deeply', () { + final out = validator.sanitize(''' +
+ + + + A + + + +
+ '''); + + expect(out.contains('onclick='), false); + expect(out.contains('onmousedown='), false); + expect(out.contains('onkeypress='), false); + expect(out.contains('X

', + ); + + expect(out.contains('color: red'), true); + expect(out.contains('position'), false); + expect(out.contains('javascript'), false); + }); + + test('sanitize STYLE tag as stylesheet', () { + final out = validator.sanitize(''' + +

Hi

+ '''); + + expect(out.contains('

Hi

'), true); + expect(out.contains('position'), true); + expect(out.contains('@media'), true); + }); + }); + + group('URL sanitization', () { + test('sanitize A[href] with addLinkRel', () { + final out = + validator.sanitize('
Link'); + expect(out.contains('rel="nofollow"'), true); + }); + + test('javascript: URL removed entirely', () { + final out = + validator.sanitize('Bad'); + expect(out.contains('href='), false); + }); + + test('cid: URLs are preserved', () { + final out = validator.sanitize(''); + expect(out.contains('cid:12345'), true); + }); + + test( + '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)', + ); + }); + + test('base64 image containing javascript: removed', () { + final out = validator.sanitize( + '', + ); + expect(out.contains('src='), false); + }); + + test('invalid base64 images removed (non-base64 characters)', () { + final out = validator.sanitize( + '', + ); + expect(out, contains('')); + expect(out, isNot(contains('src="data:image/png;base64,INVALID@!'))); + }); + + test('SVG data URLs in img src are currently dropped', () { + final out = validator.sanitize( + '', + ); + + // remains, but src is dropped + expect(out, contains('')); + expect(out, isNot(contains('data:image/svg+xml'))); + }); + + test('invalid image URLs removed (javascript inside src)', () { + final out = validator.sanitize(''); + expect(out.contains(''), true); + }); + + test('normalize whitespace inside non-data URLs', () { + const html = 'Link'; + final out = validator.sanitize(html); + + expect(out.contains('href="http://example.com"'), true); + }); + + test('remove dangerous unicode-escaped javascript URLs', () { + const html = + 'X'; + final out = validator.sanitize(html); + + expect(out.contains('href='), false); + expect(out.contains('alert'), false); + }); + + test('srcset sanitized properly (dropping javascript URLs)', () { + const html = ''' + + '''; + + final out = validator.sanitize(html); + + // javascript entry MUST be removed + expect(out.contains('javascript:'), false); + + // safe entry SHOULD remain + expect(out.contains('http://safe.com/image.png'), true); + }); + }); + + group('Real-world HTML structures', () { + test('complex HTML sanitized safely', () { + final out = validator.sanitize(''' +
+
+ +

Hello

+ + + + World + +
+
+ '''); + + expect(out.contains(''), true); + expect(out.contains(''), false); + expect(out.contains('World'), true); + expect(out.contains('Hello'), true); + }); + }); + }); + + group('Validator configuration – allowAttributes / allowTags', () { + test('allowTags makes unknown tag allowed', () { + final v = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: ['custom'], + ); + + expect(v.sanitize('Hello'), 'Hello'); + }); + + test('allowAttributes cannot override forbidden attributes', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: ['onclick'], + allowTags: null, + ), + throwsArgumentError, + ); + }); + + test('allowAttributes allows safe attributes', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: ['data-foo', 'title'], + allowTags: null, + ), + returnsNormally, + ); + }); + + test('allowAttributes throws when containing forbidden attribute', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: ['data-foo', 'onclick'], + allowTags: null, + ), + throwsArgumentError, + ); + }); + + test('allowAttributes = null does not throw', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ), + returnsNormally, + ); + }); + + test('allowTags cannot include forbidden tags', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: ['script'], + ), + throwsArgumentError, + ); + }); + + test('allowTags throws if any forbidden tag is included', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: ['custom', 'iframe'], + ), + throwsArgumentError, + ); + }); + + test('allowTags = null does not throw', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ), + returnsNormally, + ); + }); + + test('keeping safe custom attributes', () { + final v = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: ['data-safe'], + allowTags: null, + ); + + final out = v.sanitize('

Hi

'); + expect(out.contains('data-safe="1"'), true); + }); + + test('error message contains forbidden attribute name', () { + try { + SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: ['onclick'], + allowTags: null, + ); + fail('Expected an ArgumentError'); + } catch (e) { + expect(e.toString(), contains('onclick')); + } + }); + + test('constructor throws before sanitize() is called', () { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: ['onload'], + allowTags: null, + ), + throwsArgumentError, + ); + }); + }); + + group('ID / ClassName validation', () { + test('id validated by allowElementId', () { + final v = SaneHtmlValidator( + allowElementId: (id) => id == 'good', + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ); + + final out1 = v.sanitize('

Hi

'); + final out2 = v.sanitize('

Hi

'); + + expect(out1.contains('id='), true); + expect(out2.contains('id='), false); + }); + + test('class validated by allowClassName', () { + final v = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (c) => c.startsWith('x-'), + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ); + + final out = v.sanitize('

Hi

'); + + expect(out.contains('x-one'), true); + expect(out.contains('x-two'), true); + expect(out.contains(' two '), false); + expect(out.contains(' three'), false); + }); + }); + + group('Forbidden tag override attempts', () { + test('each forbidden tag cannot be allowed', () { + const forbidden = [ + 'input', + 'textarea', + 'select', + 'option', + 'script', + 'iframe', + 'embed', + 'object', + 'applet', + ]; + + for (final tag in forbidden) { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: [tag], + ), + throwsArgumentError, + reason: 'Tag <$tag> must not be override-allowable', + ); + } + }); + + test('forbidden form attributes cannot be allowed', () { + const forbiddenAttrs = [ + 'action', + 'method', + 'formaction', + 'formmethod', + 'enctype', + 'formtarget', + 'accept-charset', + 'autocomplete', + 'novalidate', + ]; + + for (final attr in forbiddenAttrs) { + expect( + () => SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: [attr], + allowTags: null, + ), + throwsArgumentError, + reason: 'Attribute $attr must not be override-allowable', + ); + } + }); + }); + + group('Sanitize form tags & nested form structure', () { + final validator = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: null, + allowAttributes: null, + allowTags: null, + ); + + test('removes
', () { + expect( + validator.sanitize('

Hi

'), + '

Hi

', + ); + }); + + test('removes
with interactive children', () { + final out = validator.sanitize(''' + + + +

Ok

+
+ '''); + + expect(out.contains('Ok

'), true); + }); + + test('removes nested form inside other tags', () { + final out = validator.sanitize(''' +
+
+
+ '''); + + expect(out.contains(' + + + +

Hi

+ + '''); + + expect(out.contains('Hi

'), true); + }); + + test('removes forbidden tags but keeps valid descendants', () { + final out = validator.sanitize(''' +
+
+ Hello +
+
+ '''); + + expect(out.contains('Hello'), true); + }); + + test('does not unwrap forbidden tags — always remove them', () { + final out = validator.sanitize('
Hi
'); + expect(out, 'Hi'); + }); + }); + + group('Deeply nested complex HTML', () { + late SaneHtmlValidator validator; + + setUp(() { + validator = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: (_) => ['nofollow'], + allowAttributes: null, + allowTags: null, + ); + }); + + test( + 'heavily nested forbidden tags with dangerous attributes removed correctly', + () { + const html = ''' +
+
+ + + +
+ +

Text

+ + + Bold + + + + + + + Click + + +
+
+
+
+
+
+
+ +
+
+
+ '''; + + final out = validator.sanitize(html); + + expect(out.contains(''), true); + expect(out.contains(' + + + + + + + +
+ +

Hello World

+
+
+
+
+
+
+
+
+ + '''; + + final out = validator.sanitize(html); + + expect(out.contains('

Hello World

'), true); + expect(out.contains(' + +

A

+ + + + +
+ + '''; + + final out = validator.sanitize(html); + + expect(out.contains('position'), false); + expect(out.contains('javascript:evil'), false); + expect(out.contains('onclick='), false); + expect(out.contains(''), true); + expect(out.contains('javascript:'), false); + expect(out.contains('A'), true); + }); + + test( + 'multiple nested custom tags with unsafe children removed, safe text remains', + () { + const html = ''' + + + + + + + + Hello + + + + + + + + '''; + + final out = validator.sanitize(html); + + // All wrappers removed + expect(out.contains('Hello'), true); + }); + }); + + group('Advanced XSS vectors (SVG, MathML, XMLNS, encoded)', () { + late SaneHtmlValidator validator; + + setUp(() { + validator = SaneHtmlValidator( + allowElementId: (_) => true, + allowClassName: (_) => true, + addLinkRel: (_) => ['nofollow'], + allowAttributes: null, + allowTags: null, + ); + }); + + test('strip SVG with script, animate, foreignObject, and unsafe href', + () { + const html = ''' + + + +
Test
+ Click +
+ '''; + + final out = validator.sanitize(html); + + expect(out.contains(' ', () { + const html = ''' + + + + + + + Content + + + + '''; + + final out = validator.sanitize(html); + + expect(out.contains(' + Bad + + '''; + + final out = validator.sanitize(html); + + expect(out.contains('xmlns'), false); + expect(out.contains('xlink:'), false); + expect(out.contains('javascript:'), false); + expect(out.contains('Bad'), true); + }); + + test('meta refresh removed entirely', () { + const html = ''' + +

Hello

+ '''; + + final out = validator.sanitize(html); + + expect(out.contains('Hello

'), true); + }); + + test('remove tags (stylesheet or preload)', () { + const html = ''' + + +

Hi

+ '''; + + final out = validator.sanitize(html); + + expect(out.contains('Hi

'), true); + }); + + test('percent-encoded (alert(1)) javascript URL removed', () { + const html = ''' + Click + '''; + + final out = validator.sanitize(html); + + expect(out.contains('href='), false); + expect(out.contains('javascript'), false); + }); + + test('percent-encoded (javascript) javascript URL removed', () { + const html = ''' + Click + '''; + + final out = validator.sanitize(html); + + expect(out.contains('href='), false); + expect(out.contains('alert'), false); + }); + + test('unicode-escaped javascript URL removed', () { + const html = ''' + Click + '''; + + final out = validator.sanitize(html); + + expect(out.contains('href='), false); + expect(out.contains('alert'), false); + }); + + test('base64-encoded javascript URL removed', () { + const html = ''' + + Bad + + '''; + + final out = validator.sanitize(html); + + expect(out.contains('href='), false); + expect(out.contains('amF2YX'), false); + }); + + test('data-* attributes containing JS should not leak events', () { + const html = ''' +
+ Hi +
+ '''; + + final out = validator.sanitize(html); + + expect(out.contains('onload'), false); + expect(out.contains('javascript:'), false); + expect(out.contains('data-x'), false); + expect(out.contains('data-y'), false); + expect(out.contains('Hi'), true); + }); + + test('bidi control characters inside href do not allow javascript URL', + () { + const html = 'Click'; + + final out = validator.sanitize(html); + + expect(out.contains('href='), false); + expect(out.contains('alert'), false); + expect(out.contains('Click'), true); + }); + + test('srcset javascript URL removed', () { + const html = ''; + + final out = validator.sanitize(html); + + expect(out.contains('srcset='), false); + expect(out.contains('javascript:'), false); + }); + + test('HTML comment injection cannot break attribute parsing', () { + const html = ''; + 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(' true, + allowClassName: (_) => true, + addLinkRel: (_) => ['nofollow'], + allowAttributes: null, + allowTags: null, + ); + }); + + test('XSS – strip data:, vbscript:, data:application links', () { + const html = '' + 'Firefox' + 'Internet Explorer' + 'Firefox' + 'Internet Explorer' + 'CLICK ME'; + + final out = validator.sanitize(html); + + expect(out, isNot(contains('data:text')), + reason: 'data:text/html removed'); + expect(out, isNot(contains('vbscript:')), reason: 'vbscript removed'); + expect(out, isNot(contains('data:application')), + reason: 'data:application removed'); + }); + + test('href – normalize newlines in href attribute', () { + const html = + '

FirefoxFirefox'; + + final out = validator.sanitize(html); + + expect(out, contains('href="http://test.com"')); + }); + + test('AREA – remove data:, vbscript:, javascript: in href', () { + const html = '' + '

' + 'IE

' + '' + '

' + 'IE

' + ''; + + final out = validator.sanitize(html); + + expect(out, isNot(contains('data:text'))); + expect(out, isNot(contains('vbscript:'))); + expect(out, isNot(contains('javascript:'))); + }); + + test('object – remove / but preserve children', () { + const html = ''' +
+ + +

This alternative text should survive

+
+
+'''; + + final out = validator.sanitize(html); + + expect(out, isNot(contains(' validator.sanitize(html); + + expect( + washer('

p2

'), + '

p2

', + ); + expect(washer('

para2

'), + '

para1

para2

', + ); + expect( + washer('

para1

para2

'), + '

para1

para2

', + ); + expect( + washer('

para1

para2

'), + '

para1

para2

', + ); + expect( + washer( + '

\n2\n4

'), + '

\n2\n4

', + ); + }); + + test('textarea – forbidden, remove tag and its content', () { + const html = '">'; + + final out = validator.sanitize(html); + + expect(out, isNot(contains('textarea'))); + expect(out, isNot(contains('

')); + }); + + test('CDATA – script inside CDATA removed', () { + const html = + '

alert(document.cookie)]]>

'; + + final out = validator.sanitize(html); + + expect(out, isNot(contains(''; + expect( + validator.sanitize(html), + equals(''), + ); + }); + + test('REMOVES document.cookie inside onclick attribute', () { + const html = ''; + expect( + validator.sanitize(html).contains('onclick'), + false, + ); + }); + }); + + 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, isNot(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, isNot(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'))); + }); + + test('preserves nested CSS as raw text', () { + const html = ''' + +
+'''; + + 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'))); + }); + }); + }); +} diff --git a/sanitize_html/test/sanitize_html_test.dart b/sanitize_html/test/sanitize_html_test.dart index 05b06e0f..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, @@ -29,8 +29,6 @@ void main() { return sanitizeHtml( template, - allowElementId: (id) => id == 'only-allowed-id', - allowClassName: (className) => className == 'only-allowed-class', addLinkRel: (href) => href == 'bad-link' ? ['ugc', 'nofollow'] : null, ); } @@ -65,25 +63,9 @@ void main() { testContains('

hello', '

'); testContains('

hello', '

'); - // 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'); - testContains( + testNotContains( 'hello', '//example.com/test.html'); testContains('hello', '/test.html'); testContains('hello', @@ -100,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'); @@ -129,7 +111,6 @@ void main() { testNotContains('
', '
'); testNotContains('
', ''); testContains('><', '><'); - testContains('

', '
a
'); testContains('
ab', 'ab'); @@ -152,9 +133,257 @@ void main() { withOptionalConfiguration: false); testNotContains('hey', 'rel=', withOptionalConfiguration: false); - testNotContains('hello', 'id=', - withOptionalConfiguration: false); - testNotContains('hello', 'class=', - 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 new file mode 100644 index 00000000..d2e30525 --- /dev/null +++ b/sanitize_html/test/url_validators_test.dart @@ -0,0 +1,97 @@ +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 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 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); + }); + + test('validBase64Image Non-image Base64 string', () { + String nonImageBase64 = 'data:text/plain;base64,dGVzdA=='; + expect(UrlValidators.validBase64Image(nonImageBase64), isFalse); + }); + + 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, + ); + }); + }); +}