Skip to content

Commit

Permalink
Markdown xss backport (#8244)
Browse files Browse the repository at this point in the history
* Update helpers.py

* Update mixins.py

* format

* format

* Allow horizontal rule in markdown

* More instructive error msg

* Specify output_format to markdown.markdown

Ref: https://python-markdown.github.io/reference/markdown/serializers/

* Cleanup

* Adjust allowable markdown tags

* Add unit test for malicious markdown XSS

* Allow <pre> tag

---------

Co-authored-by: Matthias Mair <[email protected]>
  • Loading branch information
SchrodingersGat and matmair authored Oct 7, 2024
1 parent 1c6d25c commit 6e37f0c
Show file tree
Hide file tree
Showing 4 changed files with 94 additions and 4 deletions.
34 changes: 34 additions & 0 deletions src/backend/InvenTree/InvenTree/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,40 @@ def remove_non_printable_characters(
return cleaned


def clean_markdown(value: str):
"""Clean a markdown string.
This function will remove javascript and other potentially harmful content from the markdown string.
"""
import markdown
from markdownify.templatetags.markdownify import markdownify

try:
markdownify_settings = settings.MARKDOWNIFY['default']
except (AttributeError, KeyError):
markdownify_settings = {}

extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', [])
extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {})

# Generate raw HTML from provided markdown (without sanitizing)
# Note: The 'html' output_format is required to generate self closing tags, e.g. <tag> instead of <tag />
html = markdown.markdown(
value or '',
extensions=extensions,
extension_configs=extension_configs,
output_format='html',
)

# Clean the HTML content (for comparison). Ideally, this should be the same as the original content
clean_html = markdownify(value)

if html != clean_html:
raise ValidationError(_('Data contains prohibited markdown content'))

return value


def hash_barcode(barcode_data):
"""Calculate a 'unique' hash for a barcode string.
Expand Down
15 changes: 12 additions & 3 deletions src/backend/InvenTree/InvenTree/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
from rest_framework.response import Response

from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags
from InvenTree.helpers import (
clean_markdown,
remove_non_printable_characters,
strip_html_tags,
)


class CleanMixin:
Expand Down Expand Up @@ -57,18 +61,20 @@ def clean_string(self, field: str, data: str) -> str:

# By default, newline characters are removed
remove_newline = True
is_markdown = False

try:
if hasattr(self, 'serializer_class'):
model = self.serializer_class.Meta.model
field = model._meta.get_field(field)

# The following field types allow newline characters
allow_newline = [InvenTreeNotesField]
allow_newline = [(InvenTreeNotesField, True)]

for field_type in allow_newline:
if issubclass(type(field), field_type):
if issubclass(type(field), field_type[0]):
remove_newline = False
is_markdown = field_type[1]
break

except AttributeError:
Expand All @@ -80,6 +86,9 @@ def clean_string(self, field: str, data: str) -> str:
cleaned, remove_newline=remove_newline
)

if is_markdown:
cleaned = clean_markdown(cleaned)

return cleaned

def clean_data(self, data: dict) -> dict:
Expand Down
8 changes: 7 additions & 1 deletion src/backend/InvenTree/InvenTree/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1231,23 +1231,29 @@
'abbr',
'b',
'blockquote',
'code',
'em',
'h1',
'h2',
'h3',
'h4',
'h5',
'hr',
'i',
'img',
'li',
'ol',
'p',
'pre',
's',
'strong',
'ul',
'table',
'thead',
'tbody',
'th',
'tr',
'td',
'ul',
],
}
}
Expand Down
41 changes: 41 additions & 0 deletions src/backend/InvenTree/company/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,47 @@ def test_company_active(self):
len(self.get(url, data={'active': False}, expected_code=200).data), 1
)

def test_company_notes(self):
"""Test the markdown 'notes' field for the Company model."""
pk = Company.objects.first().pk

# Attempt to inject malicious markdown into the "notes" field
xss = [
'[Click me](javascript:alert(123))',
'![x](javascript:alert(123))',
'![Uh oh...]("onerror="alert(\'XSS\'))',
]

for note in xss:
response = self.patch(
reverse('api-company-detail', kwargs={'pk': pk}),
{'notes': note},
expected_code=400,
)

self.assertIn(
'Data contains prohibited markdown content', str(response.data)
)

# The following markdown is safe, and should be accepted
good = [
'This is a **bold** statement',
'This is a *italic* statement',
'This is a [link](https://www.google.com)',
'This is an ![image](https://www.google.com/test.jpg)',
'This is a `code` block',
'This text has ~~strikethrough~~ formatting',
]

for note in good:
response = self.patch(
reverse('api-company-detail', kwargs={'pk': pk}),
{'notes': note},
expected_code=200,
)

self.assertEqual(response.data['notes'], note)


class ContactTest(InvenTreeAPITestCase):
"""Tests for the Contact models."""
Expand Down

0 comments on commit 6e37f0c

Please sign in to comment.