diff --git a/docs/docs/concepts/terminology.md b/docs/docs/concepts/terminology.md index ba127d9a48..95df1719ae 100644 --- a/docs/docs/concepts/terminology.md +++ b/docs/docs/concepts/terminology.md @@ -13,7 +13,7 @@ Evolves around manufacturing of parts out of other parts. It keeps track of stoc ### Part Library Management *(PLM)* Keeps track of BOMs, part variants, possible substitutions, versions, IPNs and further part parameters. -PLM can also mean product lifecycle management – those systems manage all stages from design through manufacturing up to customer support and recycling. +PLM can also mean product lifecycle management - those systems manage all stages from design through manufacturing up to customer support and recycling. A similar system is [Partkeepr](https://partkeepr.org/) (seems mostly inactive - there is a 3rd party importer). diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md index fb6bfbec57..77b9a17975 100644 --- a/docs/docs/plugins/develop.md +++ b/docs/docs/plugins/develop.md @@ -23,7 +23,7 @@ Consider the use-case for your plugin and define the exact function of the plugi - Do you need to run in the background ([ScheduleMixin](./mixins/schedule.md)) or when things in InvenTree change ([EventMixin](./mixins/event.md))? - Does the plugin need configuration that should be user changeable ([SettingsMixin](./mixins/settings.md)) or static (just use a yaml in the config dir)? - You want to receive webhooks? Do not code your own untested function, use the WebhookEndpoint model as a base and override the perform_action method. -- Do you need the full power of Django with custom models and all the complexity that comes with that – welcome to the danger zone and [AppMixin](./mixins/app.md). The plugin will be treated as a app by django and can maybe rack the whole instance. +- Do you need the full power of Django with custom models and all the complexity that comes with that - welcome to the danger zone and [AppMixin](./mixins/app.md). The plugin will be treated as a app by django and can maybe rack the whole instance. ### Define Metadata diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index ac82ed8cc9..b0740c1b68 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -890,6 +890,364 @@ class NoteAPITests(InvenTreeAPITestCase): self.assertTrue(note_a_detail.data['primary']) +class NoteContentSanitizationTests(InvenTreeAPITestCase): + """Security tests for the Note API 'content' field. + + The content field accepts raw HTML which is sanitized by nh3 before + persistence. These tests verify that known XSS vectors are neutralised + both at the model level (Note.clean()) and through the API (POST/PATCH). + """ + + def setUp(self): + """Create a Part instance to attach notes to.""" + from part.models import Part + + super().setUp() + + self.assignRole('part.add') + + self.part = Part.objects.create( + name='Security Test Part', description='Part for note security testing' + ) + + def _note_url(self, pk=None): + if pk: + return reverse('api-note-detail', kwargs={'pk': pk}) + return reverse('api-note-list') + + def _create_note_with_content(self, content, expected_code=201): + return self.post( + self._note_url(), + data={ + 'model_type': 'part', + 'model_id': self.part.pk, + 'title': 'Security Test Note', + 'content': content, + }, + expected_code=expected_code, + ) + + # ------------------------------------------------------------------------- + # Model-level sanitization (Note.clean() called directly) + # ------------------------------------------------------------------------- + + def test_model_clean_strips_script_tags(self): + """Note.clean() removes

Safe content

", + ) + note.clean() + self.assertNotIn('text

', + ) + note.clean() + self.assertNotIn('onclick', note.content.lower()) + self.assertIn('text', note.content) + + def test_model_clean_strips_javascript_protocol(self): + """Note.clean() removes javascript: from href attributes.""" + from django.contrib.contenttypes.models import ContentType + + from common.models import Note + + ct = ContentType.objects.get_for_model(self.part.__class__) + note = Note( + model_type=ct, + model_id=self.part.pk, + title='Protocol test', + content='link', + ) + note.clean() + self.assertNotIn('javascript:', note.content.lower()) + + # ------------------------------------------------------------------------- + # API - script injection (POST) + # ------------------------------------------------------------------------- + + def test_api_script_tag_stripped(self): + """

hello

" + ) + content = response.data['content'] + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content("") + self.assertNotIn('") + self.assertNotIn('onerror', response.data['content'].lower()) + + def test_api_onload_handler_stripped(self): + """Onload attribute is stripped (e.g. on svg tags).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('onload', response.data['content'].lower()) + + def test_api_onclick_handler_stripped(self): + """Onclick attribute is stripped from otherwise-allowed tags.""" + response = self._create_note_with_content("

click me

") + self.assertNotIn('onclick', response.data['content'].lower()) + + def test_api_onmouseover_handler_stripped(self): + """Onmouseover attribute is stripped.""" + response = self._create_note_with_content("hover") + self.assertNotIn('onmouseover', response.data['content'].lower()) + + def test_api_onfocus_handler_stripped(self): + """Onfocus attribute on an input element is stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('onfocus', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - javascript: / vbscript: protocol injection + # ------------------------------------------------------------------------- + + def test_api_javascript_href_stripped(self): + """javascript: href is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_javascript_href_uppercase_stripped(self): + """JAVASCRIPT: href (uppercase) is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_vbscript_href_stripped(self): + """vbscript: href is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('vbscript:', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - dangerous tag removal + # ------------------------------------------------------------------------- + + def test_api_iframe_stripped(self): + """" + ) + self.assertNotIn(' tags are stripped entirely.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped entirely.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped (prevents base-URL hijacking).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped (prevents external stylesheet injection).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped (prevents CSRF / phishing via injected forms).""" + response = self._create_note_with_content( + "
" + ) + self.assertNotIn('x" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_style_expression_stripped(self): + """IE-era CSS expression() is stripped from style attributes.""" + response = self._create_note_with_content( + '

x

' + ) + self.assertNotIn('expression(', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - SVG-based XSS + # ------------------------------------------------------------------------- + + def test_api_svg_onload_stripped(self): + """SVG with onload handler is sanitized.""" + response = self._create_note_with_content( + "" + "" + ) + self.assertNotIn('onload', response.data['content'].lower()) + + def test_api_svg_animate_javascript_stripped(self): + """SVG animate element with javascript: href value is stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - data URI injection + # ------------------------------------------------------------------------- + + def test_api_data_uri_in_img_src_stripped(self): + """data: URI in img src containing a script payload is stripped.""" + response = self._create_note_with_content( + '' + ) + content = response.data['content'] + self.assertNotIn('Original safe content

') + pk = note.data['pk'] + + response = self.patch( + self._note_url(pk), + data={'content': "

Updated

"}, + expected_code=200, + ) + content = response.data['content'] + self.assertNotIn('', content) + self.assertIn('', content) + + def test_safe_https_link_preserved(self): + """An anchor with an https:// href is kept after sanitization.""" + response = self._create_note_with_content( + 'documentation' + ) + content = response.data['content'] + self.assertIn('https://example.com', content) + self.assertIn('documentation', content) + + def test_blockquote_preserved(self): + """Block-level formatting elements such as blockquote are preserved.""" + response = self._create_note_with_content( + '

Quoted text

' + ) + content = response.data['content'] + self.assertIn('
', content) + self.assertIn('Quoted text', content) + + def test_empty_content_accepted(self): + """An empty content field is valid and stored as-is.""" + response = self._create_note_with_content('') + self.assertEqual(response.data['content'], '') + + def test_plain_text_content_preserved(self): + """Plain text with no HTML tags is stored without modification.""" + plain = 'Just plain text, no HTML here.' + response = self._create_note_with_content(plain) + self.assertEqual(response.data['content'], plain) + + def test_html_entities_in_plain_text_not_executed(self): + """HTML-entity-encoded script tags in plain text are not executed as markup.""" + # <script> is already-escaped user text — it should be stored + # safely and not interpreted as a tag. + entity_payload = '<script>alert(1)</script>' + response = self._create_note_with_content(entity_payload) + content = response.data['content'] + # Must not contain a live