2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-22 01:06:50 +00:00

Remove image download support (#11962)

* Remove image download support

- Helper function remains (it is used in the supplier plugin mixin)
- No longer available to user
- Close massive security hole entirely
- Will be defunct soon anyway (moving to generic attachments)

* Update CHANGELOG.md

* Bump API version

* Fix for unit tests
This commit is contained in:
Oliver
2026-05-19 07:02:05 +10:00
committed by GitHub
parent acc2786e44
commit 99358eb4e7
12 changed files with 24 additions and 194 deletions
@@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 488
INVENTREE_API_VERSION = 489
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v489 -> 2026-05-18 : https://github.com/inventree/InvenTree/pull/11962
- Removes the "remote_image" field from the Part API endpoint
- Removes the "remote_image" field from the Company API endpoint
v488 -> 2026-05-17 : https://github.com/inventree/InvenTree/pull/11920
- Allow renaming of attachments after upload via the API
@@ -120,7 +120,12 @@ def validate_url_no_ssrf(url):
raise ValueError(_('URL points to a private or reserved IP address'))
def download_image_from_url(remote_url, timeout=2.5):
def download_image_from_url(
remote_url: str,
timeout: float = 2.5,
user_agent: str = '',
max_size: Optional[int] = None,
):
"""Download an image file from a remote URL.
This is a potentially dangerous operation, so we must perform some checks:
@@ -130,8 +135,9 @@ def download_image_from_url(remote_url, timeout=2.5):
Arguments:
remote_url: The remote URL to retrieve image
max_size: Maximum allowed image size (default = 1MB)
timeout: Connection timeout in seconds (default = 5)
user_agent: User-Agent string to use for the request (optional)
max_size: Maximum allowed image size (in bytes) (default = 1MB)
Returns:
An in-memory PIL image file, if the download was successful
@@ -151,13 +157,9 @@ def download_image_from_url(remote_url, timeout=2.5):
validate_url_no_ssrf(remote_url)
# Calculate maximum allowable image size (in bytes)
max_size = (
int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
)
max_size = max_size or 1 * 1024 * 1024 # Default to 1MB if not provided
# Add user specified user-agent to request (if specified)
user_agent = get_global_setting('INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT')
headers = {'User-Agent': user_agent} if user_agent else None
try:
@@ -26,7 +26,6 @@ from rest_framework.serializers import DecimalField, Serializer
from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer
import common.models as common_models
import InvenTree.ready
from common.currency import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
@@ -785,51 +784,6 @@ class NotesFieldMixin:
self.fields.pop('notes', None)
class RemoteImageMixin(metaclass=serializers.SerializerMetaclass):
"""Mixin class which allows downloading an 'image' from a remote URL.
Adds the optional, write-only `remote_image` field to the serializer
"""
def skip_create_fields(self):
"""Ensure the 'remote_image' field is skipped when creating a new instance."""
return ['remote_image']
remote_image = serializers.URLField(
required=False,
allow_blank=True,
write_only=True,
label=_('Remote Image'),
help_text=_('URL of remote image file'),
)
def validate_remote_image(self, url):
"""Perform custom validation for the remote image URL.
- Attempt to download the image and store it against this object instance
- Catches and re-throws any errors
"""
from InvenTree.helpers_model import download_image_from_url
if not url:
return
if not common_models.InvenTreeSetting.get_setting(
'INVENTREE_DOWNLOAD_FROM_URL'
):
raise ValidationError(
_('Downloading images from remote URL is not enabled')
)
try:
self.remote_image_file = download_image_from_url(url)
except Exception:
self.remote_image_file = None
raise ValidationError(_('Failed to download image from remote URL'))
return url
class ContentTypeField(serializers.ChoiceField):
"""Serializer field which represents a ContentType as 'app_label.model_name'.
+3 -14
View File
@@ -738,21 +738,10 @@ class TestHelpers(TestCase):
large_img = 'https://github.com/inventree/InvenTree/raw/master/src/backend/InvenTree/InvenTree/static/img/paper_splash_large.jpg'
InvenTreeSetting.set_setting(
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 1, change_user=None
)
# Attempt to download an image which is too large
with self.assertRaises(ValueError):
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
# Increase allowable download size
InvenTreeSetting.set_setting(
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', 5, change_user=None
)
# Download a valid image (should not throw an error)
InvenTree.helpers_model.download_image_from_url(large_img, timeout=10)
InvenTree.helpers_model.download_image_from_url(
large_img, timeout=10, max_size=10 * 1024 * 1024
)
def test_model_mixin(self):
"""Test the getModelsWithMixin function."""
@@ -286,26 +286,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'choices': common.currency.currency_exchange_plugins,
'default': 'inventreecurrencyexchange',
},
'INVENTREE_DOWNLOAD_FROM_URL': {
'name': _('Download from URL'),
'description': _('Allow download of remote images and files from external URL'),
'validator': bool,
'default': False,
},
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE': {
'name': _('Download Size Limit'),
'description': _('Maximum allowable download size for remote image'),
'units': 'MB',
'default': 1,
'validator': [int, MinValueValidator(1), MaxValueValidator(25)],
},
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT': {
'name': _('User-agent used to download from URL'),
'description': _(
'Allow to override the user-agent used to download images and files from external URL (leave blank for the default)'
),
'default': '',
},
'INVENTREE_STRICT_URLS': {
'name': _('Strict URL Validation'),
'description': _('Require schema specification when validating URLs'),
@@ -1,8 +1,5 @@
"""JSON serializers for Company app."""
import io
from django.core.files.base import ContentFile
from django.db.models import Prefetch
from django.utils.translation import gettext_lazy as _
@@ -26,7 +23,6 @@ from InvenTree.serializers import (
InvenTreeTagModelSerializer,
NotesFieldMixin,
OptionalField,
RemoteImageMixin,
)
from .models import (
@@ -113,7 +109,6 @@ class CompanySerializer(
FilterableSerializerMixin,
DataImportExportSerializerMixin,
NotesFieldMixin,
RemoteImageMixin,
InvenTreeModelSerializer,
):
"""Serializer for Company object (full detail)."""
@@ -145,7 +140,6 @@ class CompanySerializer(
'notes',
'parts_supplied',
'parts_manufactured',
'remote_image',
'primary_address',
'tax_id',
'parameters',
@@ -193,27 +187,6 @@ class CompanySerializer(
parameters = common.filters.enable_parameters_filter()
def save(self):
"""Save the Company instance."""
super().save()
company = self.instance
# Check if an image was downloaded from a remote URL
remote_img = getattr(self, 'remote_image_file', None)
if remote_img and company:
fmt = remote_img.format or 'PNG'
buffer = io.BytesIO()
remote_img.save(buffer, format=fmt)
# Construct a simplified name for the image
filename = f'company_{company.pk}_image.{fmt.lower()}'
company.image.save(filename, ContentFile(buffer.getvalue()))
return self.instance
@register_importer()
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
+1 -1
View File
@@ -39,7 +39,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
session.extract_columns()
self.assertEqual(session.column_mappings.count(), 15)
self.assertEqual(session.column_mappings.count(), 14)
# Check some of the field mappings
for field, col in [
+2 -18
View File
@@ -1,11 +1,9 @@
"""DRF data serializers for Part app."""
import io
import os
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q
@@ -559,7 +557,6 @@ class PartSerializer(
InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin,
InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.RemoteImageMixin,
InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer,
):
@@ -592,7 +589,6 @@ class PartSerializer(
'description',
'full_name',
'image',
'remote_image',
'existing_image',
'IPN',
'is_template',
@@ -662,7 +658,7 @@ class PartSerializer(
# These fields are only used for the LIST API endpoint
for f in self.skip_create_fields():
# Fields required for certain operations, but are not part of the model
if f in ['remote_image', 'existing_image']:
if f in ['existing_image']:
continue
self.fields.pop(f, None)
@@ -1108,6 +1104,7 @@ class PartSerializer(
part = self.instance
data = self.validated_data
# TODO: Remove the existing_image field entirely!
existing_image = data.pop('existing_image', None)
if existing_image:
@@ -1116,19 +1113,6 @@ class PartSerializer(
part.image = img_path
part.save()
# Check if an image was downloaded from a remote URL
remote_img = getattr(self, 'remote_image_file', None)
if remote_img and part:
fmt = remote_img.format or 'PNG'
buffer = io.BytesIO()
remote_img.save(buffer, format=fmt)
# Construct a simplified name for the image
filename = f'part_{part.pk}_image.{fmt.lower()}'
part.image.save(filename, ContentFile(buffer.getvalue()))
return self.instance