VYPR
Moderate severityNVD Advisory· Published Mar 5, 2026· Updated Mar 6, 2026

Wagtail: Improper escaping of HTML (Cross-site Scripting) on TableBlock class attributes

CVE-2026-28222

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.

PackageAffected versionsPatched versions
wagtailPyPI
< 6.3.86.3.8
wagtailPyPI
>= 6.4rc1, < 7.0.67.0.6
wagtailPyPI
>= 7.1rc1, < 7.2.37.2.3
wagtailPyPI
>= 7.3rc1, < 7.3.17.3.1

Affected products

1

Patches

4
4620423cb22c

Correctly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output

https://github.com/wagtail/wagtailMatthew WestcottFeb 23, 2026via ghsa
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&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Col 2</th><th scope="col">Col 3</th></tr>
    +                </thead>
    +                <tbody>
    +                    <tr><th scope="row" class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Row 1</th><td class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;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.
    
575c0d7c18c7

Correctly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output

https://github.com/wagtail/wagtailMatthew WestcottFeb 23, 2026via ghsa
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&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Col 2</th><th scope="col">Col 3</th></tr>
    +                </thead>
    +                <tbody>
    +                    <tr><th scope="row" class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Row 1</th><td class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;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.
    
0375094bb57c

Correctly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output

https://github.com/wagtail/wagtailMatthew WestcottFeb 23, 2026via ghsa
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&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Col 2</th><th scope="col">Col 3</th></tr>
    +                </thead>
    +                <tbody>
    +                    <tr><th scope="row" class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Row 1</th><td class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;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.
    
605a55696865

Correctly escape `class`, `rowspan` and `colspan` attributes in TableBlock HTML output

https://github.com/wagtail/wagtailMatthew WestcottFeb 23, 2026via ghsa
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&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Col 2</th><th scope="col">Col 3</th></tr>
    +                </thead>
    +                <tbody>
    +                    <tr><th scope="row" class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;1">Row 1</th><td class="x&quot; onmouseover=&quot;alert(1337)&quot; data-pwn=&quot;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

News mentions

0

No linked articles in our index yet.