Possible XSS attack in Wagtail
Description
In Wagtail before versions 2.8.1 and 2.7.2, a cross-site scripting (XSS) vulnerability exists on the page revision comparison view within the Wagtail admin interface. A user with a limited-permission editor account for the Wagtail admin could potentially craft a page revision history that, when viewed by a user with higher privileges, could perform actions with that user's credentials. The vulnerability is not exploitable by an ordinary site visitor without access to the Wagtail admin.
Patched versions have been released as Wagtail 2.7.2 (for the LTS 2.7 branch) and Wagtail 2.8.1 (for the current 2.8 branch).
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
wagtailPyPI | >= 1.9.0, < 2.7.2 | 2.7.2 |
wagtailPyPI | >= 2.8.0, < 2.8.1 | 2.8.1 |
Affected products
1Patches
161045ceefea1Apply proper HTML escaping on StreamField block comparisons
4 files changed · +176 −20
wagtail/admin/compare.py+30 −11 modified@@ -10,6 +10,11 @@ from wagtail.core import blocks +def text_from_html(val): + # Return the unescaped text content of an HTML string + return BeautifulSoup(force_str(val), 'html5lib').getText() + + class FieldComparison: is_field = True is_child_relation = False @@ -52,15 +57,18 @@ def htmldiff(self): class RichTextFieldComparison(TextFieldComparison): def htmldiff(self): return diff_text( - BeautifulSoup(force_str(self.val_a), 'html5lib').getText(), - BeautifulSoup(force_str(self.val_b), 'html5lib').getText() + text_from_html(self.val_a), + text_from_html(self.val_b) ).to_html() def get_comparison_class_for_block(block): if hasattr(block, 'get_comparison_class'): return block.get_comparison_class() - elif isinstance(block, blocks.CharBlock): + elif isinstance(block, (blocks.CharBlock, blocks.TextBlock)): + return CharBlockComparison + elif isinstance(block, blocks.RawHTMLBlock): + # Compare raw HTML blocks as if they were plain text, so that tags are shown explicitly return CharBlockComparison elif isinstance(block, blocks.RichTextBlock): return RichTextBlockComparison @@ -89,7 +97,19 @@ def has_changed(self): return self.val_a != self.val_b def htmlvalue(self, val): - return self.block.render_basic(val) + """ + Return an HTML representation of this block that is safe to be included + in comparison views + """ + return escape(text_from_html(self.block.render_basic(val))) + + def htmldiff(self): + html_val_a = self.block.render_basic(self.val_a) + html_val_b = self.block.render_basic(self.val_b) + return diff_text( + text_from_html(html_val_a), + text_from_html(html_val_b) + ).to_html() class CharBlockComparison(BlockComparison): @@ -99,13 +119,12 @@ def htmldiff(self): force_str(self.val_b) ).to_html() + def htmlvalue(self, val): + return escape(val) + class RichTextBlockComparison(BlockComparison): - def htmldiff(self): - return diff_text( - BeautifulSoup(force_str(self.val_a), 'html5lib').getText(), - BeautifulSoup(force_str(self.val_b), 'html5lib').getText() - ).to_html() + pass class StructBlockComparison(BlockComparison): @@ -219,8 +238,8 @@ def htmldiff(self): else: # Fall back to diffing the HTML representation return diff_text( - BeautifulSoup(force_str(self.val_a), 'html5lib').getText(), - BeautifulSoup(force_str(self.val_b), 'html5lib').getText() + text_from_html(self.val_a), + text_from_html(self.val_b) ).to_html()
wagtail/admin/tests/test_compare.py+123 −8 modified@@ -247,36 +247,151 @@ def test_has_changed_richtext(self): self.assertIsInstance(comparison.htmldiff(), SafeString) self.assertTrue(comparison.has_changed()) - def test_htmldiff_escapes_value(self): + def test_htmldiff_escapes_value_on_change(self): field = StreamPage._meta.get_field('body') comparison = self.comparison_class( field, StreamPage(body=StreamValue(field.stream_block, [ - ('text', "Original content", '1'), + ('text', "I <b>really</b> like original<i>ish</i> content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('text', 'I <b>really</b> like evil code <script type="text/javascript">doSomethingBad();</script>', '1'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">I <b>really</b> like <span class="deletion">original<i>ish</i> content</span><span class="addition">evil code <script type="text/javascript">doSomethingBad();</script></span></div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_escapes_value_on_addition(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('text', "Original <em>and unchanged</em> content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('text', "Original <em>and unchanged</em> content", '1'), + ('text', '<script type="text/javascript">doSomethingBad();</script>', '2'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Original <em>and unchanged</em> content</div>\n<div class="comparison__child-object addition"><script type="text/javascript">doSomethingBad();</script></div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_escapes_value_on_deletion(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('text', "Original <em>and unchanged</em> content", '1'), + ('text', '<script type="text/javascript">doSomethingBad();</script>', '2'), ])), StreamPage(body=StreamValue(field.stream_block, [ - ('text', '<script type="text/javascript">doSomethingBad();</script>', '1'), + ('text', "Original <em>and unchanged</em> content", '1'), ])), ) - self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original content</span><span class="addition"><script type="text/javascript">doSomethingBad();</script></span></div>') + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Original <em>and unchanged</em> content</div>\n<div class="comparison__child-object deletion"><script type="text/javascript">doSomethingBad();</script></div>') self.assertIsInstance(comparison.htmldiff(), SafeString) - def test_htmldiff_escapes_value_richtext(self): + def test_htmldiff_richtext_strips_tags_on_change(self): field = StreamPage._meta.get_field('body') comparison = self.comparison_class( field, StreamPage(body=StreamValue(field.stream_block, [ - ('rich_text', "Original content", '1'), + ('rich_text', "I <b>really</b> like Wagtail <3", '1'), ])), StreamPage(body=StreamValue(field.stream_block, [ - ('rich_text', '<script type="text/javascript">doSomethingBad();</script>', '1'), + ('rich_text', 'I <b>really</b> like evil code >_< <script type="text/javascript">doSomethingBad();</script>', '1'), ])), ) - self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original content</span><span class="addition">doSomethingBad();</span></div>') + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">I really like <span class="deletion">Wagtail <3</span><span class="addition">evil code >_< doSomethingBad();</span></div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_richtext_strips_tags_on_addition(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original <em>and unchanged</em> content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original <em>and unchanged</em> content", '1'), + ('rich_text', 'I <b>really</b> like evil code >_< <script type="text/javascript">doSomethingBad();</script>', '2'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Original and unchanged content</div>\n<div class="comparison__child-object addition">I really like evil code >_< doSomethingBad();</div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_richtext_strips_tags_on_deletion(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original <em>and unchanged</em> content", '1'), + ('rich_text', 'I <b>really</b> like evil code >_< <script type="text/javascript">doSomethingBad();</script>', '2'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('rich_text', "Original <em>and unchanged</em> content", '1'), + ])), + ) + + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Original and unchanged content</div>\n<div class="comparison__child-object deletion">I really like evil code >_< doSomethingBad();</div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_raw_html_escapes_value_on_change(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original<i>ish</i> content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', '<script type="text/javascript">doSomethingBad();</script>', '1'), + ])), + ) + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object"><span class="deletion">Original<i>ish</i> content</span><span class="addition"><script type="text/javascript">doSomethingBad();</script></span></div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_raw_html_escapes_value_on_addition(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original <em>and unchanged</em> content", '1'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original <em>and unchanged</em> content", '1'), + ('raw_html', '<script type="text/javascript">doSomethingBad();</script>', '2'), + ])), + ) + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Original <em>and unchanged</em> content</div>\n<div class="comparison__child-object addition"><script type="text/javascript">doSomethingBad();</script></div>') + self.assertIsInstance(comparison.htmldiff(), SafeString) + + def test_htmldiff_raw_html_escapes_value_on_deletion(self): + field = StreamPage._meta.get_field('body') + + comparison = self.comparison_class( + field, + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original <em>and unchanged</em> content", '1'), + ('raw_html', '<script type="text/javascript">doSomethingBad();</script>', '2'), + ])), + StreamPage(body=StreamValue(field.stream_block, [ + ('raw_html', "Original <em>and unchanged</em> content", '1'), + ])), + ) + self.assertEqual(comparison.htmldiff(), '<div class="comparison__child-object">Original <em>and unchanged</em> content</div>\n<div class="comparison__child-object deletion"><script type="text/javascript">doSomethingBad();</script></div>') self.assertIsInstance(comparison.htmldiff(), SafeString) def test_compare_structblock(self):
wagtail/tests/testapp/migrations/0047_rawhtmlblock.py+21 −0 added@@ -0,0 +1,21 @@ +# Generated by Django 3.0.4 on 2020-04-06 09:46 + +from django.db import migrations +import wagtail.core.blocks +import wagtail.core.fields +import wagtail.tests.testapp.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0046_personpage'), + ] + + operations = [ + migrations.AlterField( + model_name='streampage', + name='body', + field=wagtail.core.fields.StreamField([('text', wagtail.core.blocks.CharBlock()), ('rich_text', wagtail.core.blocks.RichTextBlock()), ('image', wagtail.tests.testapp.models.ExtendedImageChooserBlock()), ('product', wagtail.core.blocks.StructBlock([('name', wagtail.core.blocks.CharBlock()), ('price', wagtail.core.blocks.CharBlock())])), ('raw_html', wagtail.core.blocks.RawHTMLBlock())]), + ), + ]
wagtail/tests/testapp/models.py+2 −1 modified@@ -29,7 +29,7 @@ from wagtail.contrib.settings.models import BaseSetting, register_setting from wagtail.contrib.sitemaps import Sitemap from wagtail.contrib.table_block.blocks import TableBlock -from wagtail.core.blocks import CharBlock, RichTextBlock, StructBlock +from wagtail.core.blocks import CharBlock, RawHTMLBlock, RichTextBlock, StructBlock from wagtail.core.fields import RichTextField, StreamField from wagtail.core.models import Orderable, Page, PageManager, PageQuerySet from wagtail.documents.edit_handlers import DocumentChooserPanel @@ -972,6 +972,7 @@ class StreamPage(Page): ('name', CharBlock()), ('price', CharBlock()), ])), + ('raw_html', RawHTMLBlock()), ]) api_fields = ('body',)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-v2wc-pfq2-5cm6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-11001ghsaADVISORY
- github.com/pypa/advisory-database/tree/main/vulns/wagtail/PYSEC-2020-152.yamlghsaWEB
- github.com/wagtail/wagtail/commit/61045ceefea114c40ac4b680af58990dbe732389ghsaWEB
- github.com/wagtail/wagtail/releases/tag/v2.8.1ghsaWEB
- github.com/wagtail/wagtail/security/advisories/GHSA-v2wc-pfq2-5cm6ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.