Merge commit '0b5db2f16a9dee9a6127c9757cc7f843dbe45f57' into block-notes

This commit is contained in:
Oliver Walters 2026-06-14 11:27:47 +00:00
commit 54da8ea81e
112 changed files with 20694 additions and 18051 deletions

View File

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Breaking Changes
- [#12160](https://github.com/inventree/InvenTree/pull/12160) changes the way that remote URIs are loaded into the PDF report generator. Remote URIs (e.g. `http://` and `https://`) are now blocked by default, and can be enabled via the `REPORT_FETCH_URLS` system setting. This change was made to improve the security of the report generation process, as allowing remote URL fetching can potentially expose internal network services to SSRF attacks. Additionally, file URIs (e.g. `file://`) are now always blocked, and assets must be embedded as `data:` URIs before reaching the PDF generator.
- [#12107](https://github.com/inventree/InvenTree/pull/12107) makes a breaking change to the `SalesOrderStatusGroups` enum, fixing a bug where the "shipped" status was not included in the "active" group. This change may affect any external client applications which make use of the `SalesOrderStatusGroups` enum, as the "shipped" status will now be included in the "active" group instead of the "complete" group. If you are using this enum in an external client application, you will need to update your application to account for this change.
- [#9604](https://github.com/inventree/InvenTree/pull/9604) refactors user API endpoint to be less ambiguous
- [#11893](https://github.com/inventree/InvenTree/pull/11893) bumps Node environment to version 24 LTS - this is only relevant if you build the frontend assets yourself

View File

@ -230,3 +230,35 @@ And the snippet file `stock_row.html` may be written as follows:
</tr>
{% endraw %}
```
## Security
Report templates are powerful by design — they have access to the full Django template language and to model data across the InvenTree database. For this reason, **template upload is restricted to staff users only**.
### URL Fetching
When WeasyPrint renders a template to PDF it can make outbound requests to load images, stylesheets, and fonts referenced in the HTML. InvenTree restricts this through a custom URL fetcher with the following rules:
| URL Type | Behavior |
|---|---|
| `data:` URIs | Always permitted — self-contained, no network access |
| `file://` | Always blocked — assets and images must be inlined as `data:` URIs before reaching WeasyPrint |
| `http` / `https` | Disabled by default, but can be blocked - see *Remote URL Fetching* below |
| Any other scheme | Always blocked |
HTTP redirects are also disabled: a URL that passes validation cannot redirect to an internal address.
### Remote URL Fetching
The **Report URL Fetching** system setting (`REPORT_FETCH_URLS`) controls whether `http://` and `https://` URLs in templates are permitted. It defaults to **disabled**.
When enabled, URLs are still validated against private, loopback, link-local, and reserved IP ranges before the request is made, preventing templates from being used as a vector for [Server-Side Request Forgery (SSRF)](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) attacks against internal network services.
!!! warning "Enable with care"
Enabling remote URL fetching allows report templates to trigger outbound HTTP requests from the InvenTree server. Only enable this if your templates genuinely require it, and ensure that templates are reviewed before deployment.
### Asset Files
Asset files uploaded through the admin interface are embedded directly into the rendered PDF as base64 `data:` URIs — they are read via the Django storage API and never loaded through WeasyPrint's URL fetcher. This means assets work correctly regardless of whether remote URL fetching is enabled, and also work with remote storage backends such as S3.
There are various [helper functions](./helpers.md#report-assets) available to assist with embedding assets into templates.

View File

@ -1,5 +1,5 @@
---
title: Report and LabelGeneration
title: Report and Label Generation
---
## Custom Reports

View File

@ -144,6 +144,7 @@ Configuration of report generation:
{{ globalsetting("REPORT_ENABLE") }}
{{ globalsetting("REPORT_DEFAULT_PAGE_SIZE") }}
{{ globalsetting("REPORT_DEBUG_MODE") }}
{{ globalsetting("REPORT_FETCH_URLS") }}
{{ globalsetting("REPORT_LOG_ERRORS") }}
### Label Printing

View File

@ -1,16 +1,19 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 504
INVENTREE_API_VERSION = 505
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v504 -> 2026-06-11 : https://github.com/inventree/InvenTree/pull/11971
v505 -> 2026-06-16 : https://github.com/inventree/InvenTree/pull/11971
- Removes direct "notes" field from any models which previously supported markdown notes
- Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship
- Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API
v504 -> 2026-06-13 : https://github.com/inventree/InvenTree/pull/12139
- Adjustments to the SelectionList and SelectionListEntry API endpoints to support more efficient queries and data retrieval
v503 -> 2026-06-11 : https://github.com/inventree/InvenTree/pull/12155
- Adds additional filtering and sorting options to the LabelTemplate API endpoint
- Adds additional filtering and sorting options to the ReportTemplate API endpoint

View File

@ -55,6 +55,10 @@ try:
except (KeyError, IndexError):
logger.warning('INVE-W1: Current branch could not be detected.')
main_branch = None
if main_repo is not None:
main_repo.close()
main_repo = None
except ImportError:
logger.warning(
'INVE-W2: Dulwich module not found, git information will not be available.'

View File

@ -1283,29 +1283,25 @@ class IconList(ListAPI):
return list(get_icon_packs().values())
class SelectionListList(ListCreateAPI):
class SelectionListMixin(OutputOptionsMixin):
"""Mixin for SelectionList views."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class SelectionListList(SelectionListMixin, ListCreateAPI):
"""List view for SelectionList objects."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class SelectionListDetail(RetrieveUpdateDestroyAPI):
class SelectionListDetail(SelectionListMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionList object."""
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
return self.serializer_class.annotate_queryset(super().get_queryset())
class EntryMixin:
"""Mixin for SelectionEntry views."""
@ -1322,6 +1318,12 @@ class EntryMixin:
queryset = queryset.prefetch_related('list')
return queryset
def perform_destroy(self, instance):
"""Prevent deletion of entries belonging to a locked selection list."""
if instance.list.locked:
raise PermissionDenied(_('Selection list is locked'))
super().perform_destroy(instance)
class SelectionEntryList(EntryMixin, ListCreateAPI):
"""List view for SelectionEntry objects."""

View File

@ -0,0 +1,29 @@
"""Migration to change NotificationMessage object_id fields from PositiveIntegerField to CharField.
This allows notifications to reference models that use non-integer primary keys,
such as UUIDField (e.g. MachineConfig), without a database overflow error.
See: https://github.com/inventree/InvenTree/issues/12131
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0043_auto_20260518_1206'),
]
operations = [
migrations.AlterField(
model_name='notificationmessage',
name='target_object_id',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='notificationmessage',
name='source_object_id',
field=models.CharField(max_length=255, null=True, blank=True),
),
]

View File

@ -1680,7 +1680,7 @@ class NotificationMessage(models.Model):
ContentType, on_delete=models.CASCADE, related_name='notification_target'
)
target_object_id = models.PositiveIntegerField()
target_object_id = models.CharField(max_length=255)
target_object = GenericForeignKey('target_content_type', 'target_object_id')
@ -1693,7 +1693,7 @@ class NotificationMessage(models.Model):
blank=True,
)
source_object_id = models.PositiveIntegerField(null=True, blank=True)
source_object_id = models.CharField(max_length=255, null=True, blank=True)
source_object = GenericForeignKey('source_content_type', 'source_object_id')

View File

@ -1109,15 +1109,17 @@ class SelectionEntrySerializer(InvenTreeModelSerializer):
def validate(self, attrs):
"""Ensure that the selection list is not locked."""
ret = super().validate(attrs)
if self.instance and self.instance.list.locked:
list_obj = attrs.get('list') or (self.instance and self.instance.list)
if list_obj and list_obj.locked:
raise serializers.ValidationError({'list': _('Selection list is locked')})
return ret
class SelectionListSerializer(InvenTreeModelSerializer):
class SelectionListSerializer(FilterableSerializerMixin, InvenTreeModelSerializer):
"""Serializer for a selection list."""
_choices_validated: dict = {}
_choices_provided: bool = False
class Meta:
"""Meta options for SelectionListSerializer."""
@ -1134,81 +1136,25 @@ class SelectionListSerializer(InvenTreeModelSerializer):
'default',
'created',
'last_updated',
'choices',
'entry_count',
'choices',
]
default = SelectionEntrySerializer(read_only=True, allow_null=True, many=False)
choices = SelectionEntrySerializer(source='entries', many=True, required=False)
entry_count = serializers.IntegerField(read_only=True)
choices = OptionalField(
serializer_class=SelectionEntrySerializer,
serializer_kwargs={'source': 'entries', 'many': True, 'read_only': True},
prefetch_fields=['entries'],
default_include=True,
)
@staticmethod
def annotate_queryset(queryset):
"""Add count of entries for each selection list."""
return queryset.annotate(entry_count=Count('entries'))
def is_valid(self, *, raise_exception=False):
"""Validate the selection list. Choices are validated separately."""
choices = (
self.initial_data.pop('choices')
if self.initial_data.get('choices') is not None
else []
)
# Validate the choices
_choices_validated = []
db_entries = (
{a.id: a for a in self.instance.entries.all()} if self.instance else {}
)
for choice in choices:
current_inst = db_entries.get(choice.get('id'))
serializer = SelectionEntrySerializer(
instance=current_inst,
data={'list': current_inst.list.pk if current_inst else None, **choice},
)
serializer.is_valid(raise_exception=raise_exception)
_choices_validated.append({
**serializer.validated_data,
'id': choice.get('id'),
})
self._choices_validated = _choices_validated
return super().is_valid(raise_exception=raise_exception)
def create(self, validated_data):
"""Create a new selection list. Save the choices separately."""
list_entry = common_models.SelectionList.objects.create(**validated_data)
for choice_data in self._choices_validated:
common_models.SelectionListEntry.objects.create(**{
**choice_data,
'list': list_entry,
})
return list_entry
def update(self, instance, validated_data):
"""Update an existing selection list. Save the choices separately."""
inst_mapping = {inst.id: inst for inst in instance.entries.all()}
existing_ids = {a.get('id') for a in self._choices_validated}
# Perform creations and updates.
ret = []
for data in self._choices_validated:
list_inst = data.get('list', None)
inst = inst_mapping.get(data.get('id'))
if inst is None:
if list_inst is None:
data['list'] = instance
ret.append(SelectionEntrySerializer().create(data))
else:
ret.append(SelectionEntrySerializer().update(inst, data))
# Perform deletions.
for entry_id in inst_mapping.keys() - existing_ids:
inst_mapping[entry_id].delete()
return super().update(instance, validated_data)
def validate(self, attrs):
"""Ensure that the selection list is not locked."""
ret = super().validate(attrs)

View File

@ -677,6 +677,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'REPORT_FETCH_URLS': {
'name': _('Report URL Fetching'),
'description': _('Allow fetching of remote URLs when generating reports'),
'default': False,
'validator': bool,
},
'REPORT_LOG_ERRORS': {
'name': _('Log Report Errors'),
'description': _('Log errors which occur when generating reports'),

View File

@ -11,6 +11,7 @@ from PIL import Image
from taggit.models import Tag
import common.models
from common.models import SelectionList, SelectionListEntry
from InvenTree.unit_test import InvenTreeAPITestCase
@ -1621,3 +1622,69 @@ class TagAPITests(InvenTreeAPITestCase):
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks)
class SelectionListLockedTest(InvenTreeAPITestCase):
"""Tests that a locked SelectionList rejects all entry mutations."""
def setUp(self):
"""Create a locked SelectionList with one entry."""
super().setUp()
self.sel_list = SelectionList.objects.create(name='Locked List', locked=True)
self.entry = SelectionListEntry.objects.create(
list=self.sel_list, value='v1', label='Entry 1'
)
self.list_url = reverse(
'api-selectionlist-detail', kwargs={'pk': self.sel_list.pk}
)
self.entry_list_url = reverse(
'api-selectionlistentry-list', kwargs={'pk': self.sel_list.pk}
)
self.entry_detail_url = reverse(
'api-selectionlistentry-detail',
kwargs={'pk': self.sel_list.pk, 'entrypk': self.entry.pk},
)
def test_create_entry_locked(self):
"""POST a new entry to a locked list should be rejected."""
response = self.post(
self.entry_list_url,
{'list': self.sel_list.pk, 'value': 'v2', 'label': 'Entry 2'},
expected_code=400,
)
self.assertIn('list', response.data)
self.assertIn('locked', str(response.data['list']).lower())
def test_update_entry_locked(self):
"""PATCH an entry on a locked list should be rejected."""
response = self.patch(
self.entry_detail_url, {'label': 'Changed'}, expected_code=400
)
self.assertIn('list', response.data)
self.assertIn('locked', str(response.data['list']).lower())
def test_delete_entry_locked(self):
"""DELETE an entry from a locked list should be rejected."""
self.delete(self.entry_detail_url, expected_code=403)
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())
def test_patch_list_with_choices_locked(self):
"""PATCH the list with a choices payload should be rejected when locked."""
response = self.patch(
self.list_url,
{'choices': [{'value': 'v2', 'label': 'New'}]},
expected_code=400,
)
self.assertIn('locked', response.data)
def test_patch_list_without_choices_preserves_entries(self):
"""PATCH the list without choices should not touch entries (even when unlocked)."""
self.sel_list.locked = False
self.sel_list.save()
self.patch(self.list_url, {'name': 'Renamed List'}, expected_code=200)
# Entry must still exist — omitting choices must not delete entries
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())

View File

@ -2164,43 +2164,58 @@ class SelectionListTest(InvenTreeAPITestCase):
# Test adding a new list via the API
response = self.post(
reverse('api-selectionlist-list'),
{
'name': 'New List',
'active': True,
'choices': [{'value': '1', 'label': 'Test Entry'}],
},
{'name': 'New List', 'active': True},
expected_code=201,
)
list_pk = response.data['pk']
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
entry_list_url = reverse('api-selectionlistentry-list', kwargs={'pk': list_pk})
# Add an entry via the entry API
response = self.post(
entry_list_url,
{'list': list_pk, 'value': '1', 'label': 'Test Entry'},
expected_code=201,
)
entry_pk = response.data['id']
self.assertEqual(response.data['value'], '1')
self.assertEqual(response.data['label'], 'Test Entry')
# Verify the entry appears in the list's choices
response = self.get(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
data={'choices': True},
expected_code=200,
)
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '1')
# Test editing the list choices via the API (remove and add in same call)
response = self.patch(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
{'choices': [{'value': '2', 'label': 'New Label'}]},
expected_code=200,
# Edit the entry via the entry detail API
entry_url = reverse(
'api-selectionlistentry-detail', kwargs={'pk': list_pk, 'entrypk': entry_pk}
)
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '2')
self.assertEqual(response.data['choices'][0]['label'], 'New Label')
entry_id = response.data['choices'][0]['id']
response = self.patch(entry_url, {'label': 'Updated Label'}, expected_code=200)
self.assertEqual(response.data['value'], '1')
self.assertEqual(response.data['label'], 'Updated Label')
# Test changing an entry via list API
response = self.patch(
# Add a second entry, then delete the first via the entry detail API
self.post(
entry_list_url,
{'list': list_pk, 'value': '2', 'label': 'Second Entry'},
expected_code=201,
)
self.delete(entry_url, expected_code=204)
# Verify only the second entry remains
response = self.get(
reverse('api-selectionlist-detail', kwargs={'pk': list_pk}),
{'choices': [{'id': entry_id, 'value': '2', 'label': 'New Label Text'}]},
data={'choices': True},
expected_code=200,
)
self.assertEqual(response.data['name'], 'New List')
self.assertTrue(response.data['active'])
self.assertEqual(len(response.data['choices']), 1)
self.assertEqual(response.data['choices'][0]['value'], '2')
self.assertEqual(response.data['choices'][0]['label'], 'New Label Text')
def test_api_locked(self):
"""Test editing with locked/unlocked list."""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -523,7 +523,7 @@ class OrderTest(ExchangeRateMixin, PluginRegistryMixin, TestCase):
msg = messages.first()
self.assertEqual(msg.target_object_id, 1)
self.assertEqual(msg.target_object_id, str(1))
self.assertEqual(msg.name, 'Overdue Purchase Order')
def test_new_po_notification(self):

View File

@ -15,6 +15,7 @@ from common.models import DataOutput
from InvenTree.helpers import str2bool
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin, SettingsMixin
from report.fetcher import InvenTreeURLFetcher
from report.models import LabelTemplate
logger = structlog.get_logger('inventree')
@ -168,7 +169,7 @@ class InvenTreeLabelSheetPlugin(LabelPrintingMixin, SettingsMixin, InvenTreePlug
generated_file = ContentFile(html_data, 'labels.html')
else:
# Render HTML to PDF
html = weasyprint.HTML(string=html_data)
html = weasyprint.HTML(string=html_data, url_fetcher=InvenTreeURLFetcher())
document = html.render().write_pdf()
generated_file = ContentFile(document, 'labels.pdf')

View File

@ -147,6 +147,7 @@ def get_git_log(path):
datetime.datetime.fromtimestamp(commit.author_time).isoformat(),
commit.message.decode().split('\n')[0],
]
repo.close()
except KeyError:
logger.debug('No HEAD tag found in git repo at path %s', path)
except NotGitRepository:

View File

@ -0,0 +1,61 @@
"""WeasyPrint URL fetcher with security restrictions for report generation."""
from urllib.parse import urlparse
import structlog
from weasyprint.urls import URLFetcher
logger = structlog.get_logger('inventree')
class InvenTreeURLFetcher(URLFetcher):
"""WeasyPrint URL fetcher restricted to safe origins."""
def __init__(self, **kwargs):
"""Disable redirect following so a same-origin URL cannot be used for SSRF."""
kwargs.setdefault('allow_redirects', False)
super().__init__(**kwargs)
def fetch(self, url, headers=None):
"""Validate *url* before delegating to the parent fetcher."""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
if scheme in ('data', 'http', 'https'):
self._validate_http_url(url, parsed)
return super().fetch(url, headers)
if scheme == 'file':
logger.warning("InvenTreeURLFetcher: blocked file:// URL: '%s'", url)
raise ValueError(
f'file:// URLs are not permitted in report templates: {url}'
)
raise ValueError(f"URL scheme '{scheme}' is not permitted in report templates")
def _validate_http_url(self, url: str, parsed) -> None:
"""Raise if HTTP/HTTPS fetching is disabled or the URL is an SSRF risk."""
from common.settings import get_global_setting
from InvenTree.helpers_model import validate_url_no_ssrf
if not parsed.netloc:
# data: URIs — self-contained, no network access required.
return
if not get_global_setting('REPORT_FETCH_URLS', cache=False):
logger.warning(
"InvenTreeURLFetcher: blocked URL '%s': remote fetching is disabled (REPORT_FETCH_URLS=False)",
url,
)
raise ValueError(
f'Remote URL fetching is disabled in report templates: {url}'
)
try:
validate_url_no_ssrf(url)
except ValueError:
logger.warning(
"InvenTreeURLFetcher: blocked URL '%s': resolves to a private or reserved address",
url,
)
raise

View File

@ -36,6 +36,8 @@ from plugin.registry import registry
try:
from weasyprint import HTML
from report.fetcher import InvenTreeURLFetcher
except OSError as err: # pragma: no cover
print(f'OSError: {err}')
print("Unable to import 'weasyprint' module.")
@ -277,7 +279,9 @@ class ReportTemplateBase(
bytes: PDF data
"""
html = self.render_as_string(instance, context=context, **kwargs)
pdf = HTML(string=html).write_pdf(pdf_forms=True)
pdf = HTML(string=html, url_fetcher=InvenTreeURLFetcher()).write_pdf(
pdf_forms=True
)
return pdf

View File

@ -2,6 +2,7 @@
import base64
import logging
import mimetypes
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from io import BytesIO
@ -290,13 +291,21 @@ def asset(filename: str, raise_error: bool = False) -> str | None:
else:
return None
# In debug mode, return a web URL to the asset file (rather than a local file path)
# In debug mode, return a web URL to the asset file (rather than encoded data)
if get_global_setting('REPORT_DEBUG_MODE', cache=False):
return default_storage.url(str(full_path))
storage_path = default_storage.path(str(full_path))
file_data = get_media_file_contents(full_path, raise_error=raise_error)
return f'file://{storage_path}'
if not file_data:
return None
mime_type, _encoding = mimetypes.guess_type(str(filename))
if not mime_type:
mime_type = 'application/octet-stream'
encoded = base64.b64encode(file_data).decode('ascii')
return f'data:{mime_type};base64,{encoded}'
@register.simple_tag()

View File

@ -88,7 +88,9 @@ class ReportTagTest(PartImageTestMixin, InvenTreeTestCase):
self.debug_mode(False)
asset = report_tags.asset('test.txt')
self.assertEqual(asset, f'file://{settings.MEDIA_ROOT}/report/assets/test.txt')
# Non-debug mode returns a base64 data: URI (no file:// URLs)
self.assertIsNotNone(asset)
self.assertTrue(asset.startswith('data:text/plain;base64,'))
# Test for attempted path traversal
with self.assertRaises(ValidationError):

View File

@ -2,12 +2,14 @@
import os
from io import StringIO
from unittest.mock import patch
from django.apps import apps
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.test import TestCase
from django.urls import reverse
from pypdf import PdfReader
@ -871,3 +873,203 @@ class AdminTest(AdminTestCase):
def test_admin(self):
"""Test the admin URL."""
self.helper(model=ReportTemplate)
class URLFetcherE2ETest(ReportTest):
"""End-to-end test: the URL fetcher blocks malicious URLs during a real PDF render.
Extends ReportTest so that fixtures, auth, and default report templates are all
available. The print task runs synchronously in test mode (no workers), so the
render completes inline and log output is captured within the same request.
"""
def test_file_url_blocked_in_render(self):
"""A template embedding a file:// URL must still produce a PDF, but the URL must be blocked and logged."""
from io import StringIO
# Upload a minimal report template that embeds a malicious file:// reference.
html = (
'<html><body>'
'<img src="file:///etc/passwd">'
'<p>Security test content</p>'
'</body></html>'
)
template_io = StringIO(html)
template_io.name = 'security_test_template.html'
response = self.post(
reverse('api-report-template-list'),
data={
'name': 'Security Test',
'description': 'Tests that file:// URLs are blocked during rendering',
'template': template_io,
'model_type': 'stockitem',
},
format=None,
expected_code=201,
)
template_pk = response.data['pk']
item = StockItem.objects.first()
self.assertIsNotNone(item)
# Render the template. WeasyPrint catches the ValueError from our fetcher and
# continues, so the PDF is still generated — the blocked resource is just skipped.
with self.assertLogs('inventree', level='WARNING') as captured:
response = self.post(
reverse('api-report-print'),
{'template': template_pk, 'items': [item.pk]},
expected_code=201,
)
# A PDF output should have been produced despite the blocked resource.
self.assertTrue(response.data['output'].endswith('.pdf'))
# The fetcher must have logged a warning identifying the blocked URL.
blocked_warnings = [
msg
for msg in captured.output
if 'blocked file://' in msg and '/etc/passwd' in msg
]
self.assertTrue(
blocked_warnings, 'Expected a blocked file:// warning in the log output'
)
def test_ssrf_url_blocked_in_render(self):
"""A template embedding an HTTP URL to a private/reserved address must be blocked and logged."""
from io import StringIO
# 127.0.0.1 is loopback — validate_url_no_ssrf rejects it regardless of port.
html = (
'<html><body>'
'<img src="http://127.0.0.1/ssrf-probe">'
'<p>Security test content</p>'
'</body></html>'
)
template_io = StringIO(html)
template_io.name = 'ssrf_test_template.html'
response = self.post(
reverse('api-report-template-list'),
data={
'name': 'SSRF Test',
'description': 'Tests that SSRF URLs are blocked during rendering',
'template': template_io,
'model_type': 'stockitem',
},
format=None,
expected_code=201,
)
template_pk = response.data['pk']
item = StockItem.objects.first()
self.assertIsNotNone(item)
# Render the template. WeasyPrint catches the ValueError from our fetcher and
# continues, so the PDF is still generated — the blocked resource is just skipped.
with self.assertLogs('inventree', level='WARNING') as captured:
response = self.post(
reverse('api-report-print'),
{'template': template_pk, 'items': [item.pk]},
expected_code=201,
)
# A PDF output should have been produced despite the blocked resource.
self.assertTrue(response.data['output'].endswith('.pdf'))
# The fetcher must have logged a warning for the blocked SSRF attempt.
blocked_warnings = [
msg
for msg in captured.output
if 'blocked URL' in msg and '127.0.0.1' in msg
]
self.assertTrue(
blocked_warnings, 'Expected a blocked SSRF URL warning in the log output'
)
def test_fetch_urls_disabled_blocks_http(self):
"""When REPORT_FETCH_URLS is False, any http/https URL in a template must be blocked."""
from io import StringIO
from common.settings import set_global_setting
# Use a publicly routable address so the test would reach the network if
# our guard were absent — we want to confirm it is stopped by the setting,
# not by a secondary SSRF IP check.
html = (
'<html><body>'
'<img src="https://example.com/image.png">'
'<p>Security test content</p>'
'</body></html>'
)
template_io = StringIO(html)
template_io.name = 'fetch_disabled_test_template.html'
response = self.post(
reverse('api-report-template-list'),
data={
'name': 'Fetch Disabled Test',
'description': 'Tests that HTTP fetching is blocked when REPORT_FETCH_URLS=False',
'template': template_io,
'model_type': 'stockitem',
},
format=None,
expected_code=201,
)
template_pk = response.data['pk']
item = StockItem.objects.first()
self.assertIsNotNone(item)
set_global_setting('REPORT_FETCH_URLS', False, change_user=None)
with self.assertLogs('inventree', level='WARNING') as captured:
response = self.post(
reverse('api-report-print'),
{'template': template_pk, 'items': [item.pk]},
expected_code=201,
)
self.assertTrue(response.data['output'].endswith('.pdf'))
blocked_warnings = [
msg
for msg in captured.output
if 'REPORT_FETCH_URLS' in msg and 'example.com' in msg
]
self.assertTrue(
blocked_warnings, 'Expected a REPORT_FETCH_URLS warning in the log output'
)
class URLFetcherTest(TestCase):
"""Tests for InvenTreeURLFetcher security restrictions."""
def setUp(self):
"""Import fetcher for each test."""
from report.fetcher import InvenTreeURLFetcher
self.fetcher = InvenTreeURLFetcher()
def test_file_url_blocked(self):
"""file:// URLs must always be rejected regardless of path."""
for url in [
'file:///etc/passwd',
'file:///proc/self/environ',
f'file://{settings.MEDIA_ROOT}/report/assets/anything.png',
f'file://{settings.STATIC_ROOT}/some/font.ttf',
]:
with self.assertRaises(ValueError, msg=f'Expected block for {url}'):
self.fetcher.fetch(url)
def test_unknown_scheme_blocked(self):
"""Non-http/data/file schemes must be rejected."""
for url in ['ftp://example.com/file.txt', 'javascript://x']:
with self.assertRaises(ValueError, msg=f'Expected block for {url}'):
self.fetcher.fetch(url)
def test_data_uri_allowed(self):
"""data: URIs must always be permitted."""
with patch('weasyprint.urls.URLFetcher.fetch', return_value={}):
self.fetcher.fetch('data:image/png;base64,abc123')
self.fetcher.fetch('data:text/css;base64,abc123')

View File

@ -41,6 +41,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { useShallow } from 'zustand/react/shallow';
@ -168,8 +169,8 @@ export default function Calendar({
return (
<HoverCard
openDelay={300}
closeDelay={100}
openDelay={1000}
closeDelay={50}
shadow='md'
position='top-start'
>
@ -183,6 +184,40 @@ export default function Calendar({
[calendarProps.eventContent, eventTooltipContent]
);
const scrollBoxRef = useRef<HTMLDivElement>(null);
const updateMonthFromScroll = useCallback(() => {
if (!scrollBoxRef.current) return;
const container = scrollBoxRef.current;
const containerTop = container.getBoundingClientRect().top;
const cells = Array.from(
container.querySelectorAll<HTMLElement>(
'.fc-daygrid-day[data-date$="-01"]'
)
);
let dateStr: string | null = null;
for (const cell of cells) {
if (cell.getBoundingClientRect().top <= containerTop + 1) {
dateStr = cell.getAttribute('data-date');
} else {
break;
}
}
if (!dateStr) dateStr = cells[0]?.getAttribute('data-date') ?? null;
if (dateStr) {
const date = new Date(`${dateStr}T12:00:00`);
state.setMonthName(
new Intl.DateTimeFormat(calendarLocale, {
month: 'long',
year: 'numeric'
}).format(date)
);
}
}, [calendarLocale, state.setMonthName]);
const monthDayCellClassNames = useCallback(
(arg: DayCellContentArg): string[] => {
const monthClass =
@ -303,7 +338,19 @@ export default function Calendar({
)}
</Group>
</Group>
<Box pos='relative'>
<Box
ref={scrollBoxRef}
pos='relative'
onScroll={isScrollView ? updateMonthFromScroll : undefined}
{...(isScrollView && {
style: {
height: 'calc(100vh - 160px)',
overflowY: 'scroll',
scrollbarGutter: 'stable',
paddingRight: '12px'
}
})}
>
<LoadingOverlay visible={state.query.isFetching} />
<FullCalendar
ref={state.ref}
@ -316,7 +363,7 @@ export default function Calendar({
duration: { months: horizonMonths }
}
},
height: 'calc(100vh - 160px)'
height: 'auto'
})}
locales={allLocales}
locale={calendarLocale}

View File

@ -189,7 +189,7 @@ export default function OrderCalendar({
}
return (
<Group gap='xs' wrap='nowrap'>
<Group gap='xs' wrap='nowrap' style={{ paddingLeft: 5 }}>
{order.overdue && (
<ActionIcon
color='orange-7'

View File

@ -10,10 +10,13 @@ import { RenderOwner } from '../render/User';
export default function OrderCalendarToolTip({
event,
instanceLookup,
extraEntries,
modelType
}: {
event: EventContentArg;
prefix?: string | React.ReactNode;
instanceLookup: string;
extraEntries?: { label: string; value: string | React.ReactNode }[];
modelType: ModelType;
}) {
// Extract the order instance from the event
@ -23,24 +26,41 @@ export default function OrderCalendarToolTip({
if (!order) return null;
const entries = extraEntries || [];
if (order.project_code_detail?.code) {
entries.push({
label: t`Project Code`,
value: order.project_code_detail.code
});
}
return (
<Stack gap='xs'>
<Stack gap='xs' style={{ minWidth: 250 }}>
<RenderInstance model={modelType} instance={instance} />
<Divider />
<Group gap='xs'>
<Group grow gap='xs' justify='space-between'>
<Text size='sm' fw='bold'>
{order.reference}
</Text>
<Text size='xs'>{order.description || order.title}</Text>
</Group>
{entries.map((entry, index) => (
<Group key={index} grow gap='xs' justify='space-between'>
<Text size='sm' fw='bold'>
{entry.label}
</Text>
<Text size='xs'>{entry.value}</Text>
</Group>
))}
{order.start_date && (
<Group gap='xs'>
<Group grow gap='xs' justify='space-between'>
<Text size='sm' fw='bold'>{t`Start Date`}</Text>
<Text size='xs'>{formatDate(order.start_date)}</Text>
</Group>
)}
{order.target_date && (
<Group gap='xs'>
<Group grow gap='xs' justify='space-between'>
<Text size='sm' fw='bold'>{t`Target Date`}</Text>
<Text size='xs'>{formatDate(order.target_date)}</Text>
{order.overdue && (
@ -51,7 +71,7 @@ export default function OrderCalendarToolTip({
</Group>
)}
{order.responsible && (
<Group gap='xs'>
<Group grow gap='xs' justify='space-between'>
<Text size='sm' fw='bold'>{t`Responsible`}</Text>
<RenderOwner instance={order.responsible_detail} />
</Group>

View File

@ -372,3 +372,22 @@ export function useNoteFields({
};
}, [modelType, modelId, title, description, content, fetchTemplate]);
}
export function selectionListFields(): ApiFormFieldSet {
return {
name: {},
description: {},
active: {},
source_plugin: {},
source_string: {}
};
}
export function selectionEntryFields(): ApiFormFieldSet {
return {
value: {},
label: {},
description: {},
active: {}
};
}

View File

@ -1,118 +0,0 @@
import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
import { Table } from '@mantine/core';
import { useMemo } from 'react';
import RemoveRowButton from '../components/buttons/RemoveRowButton';
import { StandaloneField } from '../components/forms/StandaloneField';
import type { TableFieldRowProps } from '../components/forms/fields/TableField';
function BuildAllocateLineRow({
props
}: Readonly<{
props: TableFieldRowProps;
}>) {
const valueField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'value',
required: true,
value: props.item.value,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'value', value);
}
};
}, [props]);
const labelField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'label',
required: true,
value: props.item.label,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'label', value);
}
};
}, [props]);
const descriptionField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'string',
name: 'description',
required: true,
value: props.item.description,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'description', value);
}
};
}, [props]);
const activeField: ApiFormFieldType = useMemo(() => {
return {
field_type: 'boolean',
name: 'active',
required: true,
value: props.item.active,
onValueChange: (value: any) => {
props.changeFn(props.idx, 'active', value);
}
};
}, [props]);
return (
<Table.Tr key={`table-row-${props.item.id ?? props.idx}`}>
<Table.Td>
<StandaloneField fieldName='value' fieldDefinition={valueField} />
</Table.Td>
<Table.Td>
<StandaloneField fieldName='label' fieldDefinition={labelField} />
</Table.Td>
<Table.Td>
<StandaloneField
fieldName='description'
fieldDefinition={descriptionField}
/>
</Table.Td>
<Table.Td>
<StandaloneField fieldName='active' fieldDefinition={activeField} />
</Table.Td>
<Table.Td>
<RemoveRowButton onClick={() => props.removeFn(props.idx)} />
</Table.Td>
</Table.Tr>
);
}
export function selectionListFields(): ApiFormFieldSet {
return {
name: {},
description: {},
active: {},
locked: {},
source_plugin: {},
source_string: {},
choices: {
label: t`Entries`,
description: t`List of entries to choose from`,
field_type: 'table',
value: [],
headers: [
{ title: t`Value` },
{ title: t`Label` },
{ title: t`Description` },
{ title: t`Active` }
],
modelRenderer: (row: TableFieldRowProps) => (
<BuildAllocateLineRow props={row} />
),
addRow: () => {
return {
value: '',
label: '',
description: '',
active: true
};
}
}
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More