Wagtail: Improper escaping of HTML (Cross-site Scripting) on TableBlock class attributes
Description
Wagtail is an open source content management system built on Django. Prior to versions 6.3.8, 7.0.6, 7.2.3, and 7.3.1, a stored cross-site scripting (XSS) vulnerability exists on rendering TableBlock blocks within a StreamField. A user with access to create or edit pages containing TableBlock StreamField blocks is able to set specially-crafted class attributes on the block which run arbitrary JavaScript code when the page is viewed. When viewed by a user with higher privileges, this could lead to performing actions with that user's credentials. The vulnerability is not exploitable by an ordinary site visitor without access to the Wagtail admin, and only affects sites using TableBlock. This issue has been patched in versions 6.3.8, 7.0.6, 7.2.3, and 7.3.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
wagtailPyPI | < 6.3.8 | 6.3.8 |
wagtailPyPI | >= 6.4rc1, < 7.0.6 | 7.0.6 |
wagtailPyPI | >= 7.1rc1, < 7.2.3 | 7.2.3 |
wagtailPyPI | >= 7.3rc1, < 7.3.1 | 7.3.1 |
Affected products
1Patches
44620423cb22cCorrectly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output
2 files changed · +49 −7
wagtail/contrib/table_block/templatetags/table_block_tags.py+6 −7 modified@@ -1,5 +1,5 @@ from django import template -from django.utils.safestring import mark_safe +from django.utils.html import format_html register = template.Library() @@ -13,7 +13,7 @@ def cell_classname(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_class = classnames.get(index) if cell_class: - return mark_safe(f'class="{cell_class}"') + return format_html('class="{}"', cell_class) return "" @@ -37,10 +37,9 @@ def cell_span(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_span = spans.get(index) if cell_span: - return mark_safe( - 'rowspan="{}" colspan="{}"'.format( - cell_span["rowspan"], - cell_span["colspan"], - ) + return format_html( + 'rowspan="{}" colspan="{}"', + cell_span["rowspan"], + cell_span["colspan"], ) return ""
wagtail/contrib/table_block/tests.py+43 −0 modified@@ -93,6 +93,49 @@ def test_table_block_alignment_render(self): self.assertHTMLEqual(result, expected) self.assertIn("Test 2", result) + def test_table_block_classname_escaping(self): + value = { + "first_row_is_table_header": True, + "first_col_is_header": True, + "cell": [ + { + "row": 0, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 0, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + ], + "data": [ + ["Col 1", "Col 2", "Col 3"], + ["Row 1", "a", "b"], + ["Row 2", "c", "d"], + ], + } + block = TableBlock() + result = block.render(value) + expected = """ + <table> + <thead> + <tr><th scope="col">Col 1</th><th scope="col" class="x" onmouseover="alert(1337)" data-pwn="1">Col 2</th><th scope="col">Col 3</th></tr> + </thead> + <tbody> + <tr><th scope="row" class="x" onmouseover="alert(1337)" data-pwn="1">Row 1</th><td class="x" onmouseover="alert(1337)" data-pwn="1">a</td><td>b</td></tr> + <tr><th scope="row">Row 2</th><td>c</td><td>d</td></tr> + </tbody> + </table> + """ + + self.assertHTMLEqual(result, expected) + def test_render_empty_table(self): """ An empty table should render okay.
575c0d7c18c7Correctly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output
2 files changed · +49 −7
wagtail/contrib/table_block/templatetags/table_block_tags.py+6 −7 modified@@ -1,5 +1,5 @@ from django import template -from django.utils.safestring import mark_safe +from django.utils.html import format_html register = template.Library() @@ -13,7 +13,7 @@ def cell_classname(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_class = classnames.get(index) if cell_class: - return mark_safe(f'class="{cell_class}"') + return format_html('class="{}"', cell_class) return "" @@ -37,10 +37,9 @@ def cell_span(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_span = spans.get(index) if cell_span: - return mark_safe( - 'rowspan="{}" colspan="{}"'.format( - cell_span["rowspan"], - cell_span["colspan"], - ) + return format_html( + 'rowspan="{}" colspan="{}"', + cell_span["rowspan"], + cell_span["colspan"], ) return ""
wagtail/contrib/table_block/tests.py+43 −0 modified@@ -93,6 +93,49 @@ def test_table_block_alignment_render(self): self.assertHTMLEqual(result, expected) self.assertIn("Test 2", result) + def test_table_block_classname_escaping(self): + value = { + "first_row_is_table_header": True, + "first_col_is_header": True, + "cell": [ + { + "row": 0, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 0, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + ], + "data": [ + ["Col 1", "Col 2", "Col 3"], + ["Row 1", "a", "b"], + ["Row 2", "c", "d"], + ], + } + block = TableBlock() + result = block.render(value) + expected = """ + <table> + <thead> + <tr><th scope="col">Col 1</th><th scope="col" class="x" onmouseover="alert(1337)" data-pwn="1">Col 2</th><th scope="col">Col 3</th></tr> + </thead> + <tbody> + <tr><th scope="row" class="x" onmouseover="alert(1337)" data-pwn="1">Row 1</th><td class="x" onmouseover="alert(1337)" data-pwn="1">a</td><td>b</td></tr> + <tr><th scope="row">Row 2</th><td>c</td><td>d</td></tr> + </tbody> + </table> + """ + + self.assertHTMLEqual(result, expected) + def test_render_empty_table(self): """ An empty table should render okay.
0375094bb57cCorrectly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output
2 files changed · +49 −7
wagtail/contrib/table_block/templatetags/table_block_tags.py+6 −7 modified@@ -1,5 +1,5 @@ from django import template -from django.utils.safestring import mark_safe +from django.utils.html import format_html register = template.Library() @@ -13,7 +13,7 @@ def cell_classname(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_class = classnames.get(index) if cell_class: - return mark_safe(f'class="{cell_class}"') + return format_html('class="{}"', cell_class) return "" @@ -37,10 +37,9 @@ def cell_span(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_span = spans.get(index) if cell_span: - return mark_safe( - 'rowspan="{}" colspan="{}"'.format( - cell_span["rowspan"], - cell_span["colspan"], - ) + return format_html( + 'rowspan="{}" colspan="{}"', + cell_span["rowspan"], + cell_span["colspan"], ) return ""
wagtail/contrib/table_block/tests.py+43 −0 modified@@ -93,6 +93,49 @@ def test_table_block_alignment_render(self): self.assertHTMLEqual(result, expected) self.assertIn("Test 2", result) + def test_table_block_classname_escaping(self): + value = { + "first_row_is_table_header": True, + "first_col_is_header": True, + "cell": [ + { + "row": 0, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 0, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + ], + "data": [ + ["Col 1", "Col 2", "Col 3"], + ["Row 1", "a", "b"], + ["Row 2", "c", "d"], + ], + } + block = TableBlock() + result = block.render(value) + expected = """ + <table> + <thead> + <tr><th scope="col">Col 1</th><th scope="col" class="x" onmouseover="alert(1337)" data-pwn="1">Col 2</th><th scope="col">Col 3</th></tr> + </thead> + <tbody> + <tr><th scope="row" class="x" onmouseover="alert(1337)" data-pwn="1">Row 1</th><td class="x" onmouseover="alert(1337)" data-pwn="1">a</td><td>b</td></tr> + <tr><th scope="row">Row 2</th><td>c</td><td>d</td></tr> + </tbody> + </table> + """ + + self.assertHTMLEqual(result, expected) + def test_render_empty_table(self): """ An empty table should render okay.
605a55696865Correctly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output
2 files changed · +49 −7
wagtail/contrib/table_block/templatetags/table_block_tags.py+6 −7 modified@@ -1,5 +1,5 @@ from django import template -from django.utils.safestring import mark_safe +from django.utils.html import format_html register = template.Library() @@ -13,7 +13,7 @@ def cell_classname(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_class = classnames.get(index) if cell_class: - return mark_safe(f'class="{cell_class}"') + return format_html('class="{}"', cell_class) return "" @@ -37,10 +37,9 @@ def cell_span(context, row_index, col_index, table_header=None): index = (row_index, col_index) cell_span = spans.get(index) if cell_span: - return mark_safe( - 'rowspan="{}" colspan="{}"'.format( - cell_span["rowspan"], - cell_span["colspan"], - ) + return format_html( + 'rowspan="{}" colspan="{}"', + cell_span["rowspan"], + cell_span["colspan"], ) return ""
wagtail/contrib/table_block/tests.py+43 −0 modified@@ -93,6 +93,49 @@ def test_table_block_alignment_render(self): self.assertHTMLEqual(result, expected) self.assertIn("Test 2", result) + def test_table_block_classname_escaping(self): + value = { + "first_row_is_table_header": True, + "first_col_is_header": True, + "cell": [ + { + "row": 0, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 0, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + { + "row": 1, + "col": 1, + "className": 'x" onmouseover="alert(1337)" data-pwn="1', + }, + ], + "data": [ + ["Col 1", "Col 2", "Col 3"], + ["Row 1", "a", "b"], + ["Row 2", "c", "d"], + ], + } + block = TableBlock() + result = block.render(value) + expected = """ + <table> + <thead> + <tr><th scope="col">Col 1</th><th scope="col" class="x" onmouseover="alert(1337)" data-pwn="1">Col 2</th><th scope="col">Col 3</th></tr> + </thead> + <tbody> + <tr><th scope="row" class="x" onmouseover="alert(1337)" data-pwn="1">Row 1</th><td class="x" onmouseover="alert(1337)" data-pwn="1">a</td><td>b</td></tr> + <tr><th scope="row">Row 2</th><td>c</td><td>d</td></tr> + </tbody> + </table> + """ + + self.assertHTMLEqual(result, expected) + def test_render_empty_table(self): """ An empty table should render okay.
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
11- github.com/advisories/GHSA-p5cm-246w-84jmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-28222ghsaADVISORY
- github.com/wagtail/wagtail/commit/0375094bb57ce6e527005c2bb2e871dd20bca04dghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/commit/4620423cb22c5253391a0f04178089c1162f6e2eghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/commit/575c0d7c18c7716ed73f7a3c2720ad75956f0a85ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/commit/605a5569686565e035313222e1bc2f9802fbc55bghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/releases/tag/v6.3.8ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/releases/tag/v7.0.6ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/releases/tag/v7.2.3ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/releases/tag/v7.3.1ghsax_refsource_MISCWEB
- github.com/wagtail/wagtail/security/advisories/GHSA-p5cm-246w-84jmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.