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
+2
View File
@@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed ### Removed
- [#11962](https://github.com/inventree/InvenTree/pull/11962) removes the "remote_image" field from the Part API endpoint, which (previously) allowed the user to specify a remote URL for an image to be downloaded and associated with the part. This field was removed due to security concerns around downloading images from arbitrary URLs. If you were using this field in an external client application, you will need to update your application to use the new "download_image_from_url" API endpoint instead.
## 1.3.0 - 2026-04-11 ## 1.3.0 - 2026-04-11
### Breaking Changes ### Breaking Changes
-8
View File
@@ -33,14 +33,6 @@ Configuration of basic server settings:
{{ globalsetting("DISPLAY_FULL_NAMES") }} {{ globalsetting("DISPLAY_FULL_NAMES") }}
{{ globalsetting("DISPLAY_PROFILE_INFO") }} {{ globalsetting("DISPLAY_PROFILE_INFO") }}
{{ globalsetting("WEEK_STARTS_ON") }} {{ globalsetting("WEEK_STARTS_ON") }}
Configuration of image download settings:
| Name | Description | Default | Units |
| ---- | ----------- | ------- | ----- |
{{ globalsetting("INVENTREE_DOWNLOAD_FROM_URL") }}
{{ globalsetting("INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE") }}
{{ globalsetting("INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT") }}
{{ globalsetting("INVENTREE_STRICT_URLS") }} {{ globalsetting("INVENTREE_STRICT_URLS") }}
Configuration of various scheduled tasks: Configuration of various scheduled tasks:
@@ -1,11 +1,15 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v488 -> 2026-05-17 : https://github.com/inventree/InvenTree/pull/11920
- Allow renaming of attachments after upload via the API - 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')) 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. """Download an image file from a remote URL.
This is a potentially dangerous operation, so we must perform some checks: 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: Arguments:
remote_url: The remote URL to retrieve image remote_url: The remote URL to retrieve image
max_size: Maximum allowed image size (default = 1MB)
timeout: Connection timeout in seconds (default = 5) 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: Returns:
An in-memory PIL image file, if the download was successful 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) validate_url_no_ssrf(remote_url)
# Calculate maximum allowable image size (in bytes) # Calculate maximum allowable image size (in bytes)
max_size = ( max_size = max_size or 1 * 1024 * 1024 # Default to 1MB if not provided
int(get_global_setting('INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE')) * 1024 * 1024
)
# Add user specified user-agent to request (if specified) # 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 headers = {'User-Agent': user_agent} if user_agent else None
try: try:
@@ -26,7 +26,6 @@ from rest_framework.serializers import DecimalField, Serializer
from rest_framework.utils import model_meta from rest_framework.utils import model_meta
from taggit.serializers import TaggitSerializer from taggit.serializers import TaggitSerializer
import common.models as common_models
import InvenTree.ready import InvenTree.ready
from common.currency import currency_code_default, currency_code_mappings from common.currency import currency_code_default, currency_code_mappings
from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField
@@ -785,51 +784,6 @@ class NotesFieldMixin:
self.fields.pop('notes', None) 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): class ContentTypeField(serializers.ChoiceField):
"""Serializer field which represents a ContentType as 'app_label.model_name'. """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' 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) # 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): def test_model_mixin(self):
"""Test the getModelsWithMixin function.""" """Test the getModelsWithMixin function."""
@@ -286,26 +286,6 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'choices': common.currency.currency_exchange_plugins, 'choices': common.currency.currency_exchange_plugins,
'default': 'inventreecurrencyexchange', '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': { 'INVENTREE_STRICT_URLS': {
'name': _('Strict URL Validation'), 'name': _('Strict URL Validation'),
'description': _('Require schema specification when validating URLs'), 'description': _('Require schema specification when validating URLs'),
@@ -1,8 +1,5 @@
"""JSON serializers for Company app.""" """JSON serializers for Company app."""
import io
from django.core.files.base import ContentFile
from django.db.models import Prefetch from django.db.models import Prefetch
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -26,7 +23,6 @@ from InvenTree.serializers import (
InvenTreeTagModelSerializer, InvenTreeTagModelSerializer,
NotesFieldMixin, NotesFieldMixin,
OptionalField, OptionalField,
RemoteImageMixin,
) )
from .models import ( from .models import (
@@ -113,7 +109,6 @@ class CompanySerializer(
FilterableSerializerMixin, FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
NotesFieldMixin, NotesFieldMixin,
RemoteImageMixin,
InvenTreeModelSerializer, InvenTreeModelSerializer,
): ):
"""Serializer for Company object (full detail).""" """Serializer for Company object (full detail)."""
@@ -145,7 +140,6 @@ class CompanySerializer(
'notes', 'notes',
'parts_supplied', 'parts_supplied',
'parts_manufactured', 'parts_manufactured',
'remote_image',
'primary_address', 'primary_address',
'tax_id', 'tax_id',
'parameters', 'parameters',
@@ -193,27 +187,6 @@ class CompanySerializer(
parameters = common.filters.enable_parameters_filter() 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() @register_importer()
class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer): class ContactSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
+1 -1
View File
@@ -39,7 +39,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
session.extract_columns() session.extract_columns()
self.assertEqual(session.column_mappings.count(), 15) self.assertEqual(session.column_mappings.count(), 14)
# Check some of the field mappings # Check some of the field mappings
for field, col in [ for field, col in [
+2 -18
View File
@@ -1,11 +1,9 @@
"""DRF data serializers for Part app.""" """DRF data serializers for Part app."""
import io
import os import os
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, Q from django.db.models import ExpressionWrapper, F, Q
@@ -559,7 +557,6 @@ class PartSerializer(
InvenTree.serializers.FilterableSerializerMixin, InvenTree.serializers.FilterableSerializerMixin,
DataImportExportSerializerMixin, DataImportExportSerializerMixin,
InvenTree.serializers.NotesFieldMixin, InvenTree.serializers.NotesFieldMixin,
InvenTree.serializers.RemoteImageMixin,
InvenTree.serializers.InvenTreeTaggitSerializer, InvenTree.serializers.InvenTreeTaggitSerializer,
InvenTree.serializers.InvenTreeModelSerializer, InvenTree.serializers.InvenTreeModelSerializer,
): ):
@@ -592,7 +589,6 @@ class PartSerializer(
'description', 'description',
'full_name', 'full_name',
'image', 'image',
'remote_image',
'existing_image', 'existing_image',
'IPN', 'IPN',
'is_template', 'is_template',
@@ -662,7 +658,7 @@ class PartSerializer(
# These fields are only used for the LIST API endpoint # These fields are only used for the LIST API endpoint
for f in self.skip_create_fields(): for f in self.skip_create_fields():
# Fields required for certain operations, but are not part of the model # 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 continue
self.fields.pop(f, None) self.fields.pop(f, None)
@@ -1108,6 +1104,7 @@ class PartSerializer(
part = self.instance part = self.instance
data = self.validated_data data = self.validated_data
# TODO: Remove the existing_image field entirely!
existing_image = data.pop('existing_image', None) existing_image = data.pop('existing_image', None)
if existing_image: if existing_image:
@@ -1116,19 +1113,6 @@ class PartSerializer(
part.image = img_path part.image = img_path
part.save() 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 return self.instance
@@ -29,8 +29,6 @@ import { showNotification } from '@mantine/notifications';
import { api } from '../../App'; import { api } from '../../App';
import { InvenTreeIcon } from '../../functions/icons'; import { InvenTreeIcon } from '../../functions/icons';
import { showApiErrorMessage } from '../../functions/notifications'; import { showApiErrorMessage } from '../../functions/notifications';
import { useEditApiFormModal } from '../../hooks/UseForm';
import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { PartThumbTable } from '../../tables/part/PartThumbTable'; import { PartThumbTable } from '../../tables/part/PartThumbTable';
import { vars } from '../../theme'; import { vars } from '../../theme';
@@ -320,8 +318,7 @@ function ImageActionButtons({
apiPath, apiPath,
hasImage, hasImage,
pk, pk,
setImage, setImage
downloadImage
}: Readonly<{ }: Readonly<{
actions?: DetailImageButtonProps; actions?: DetailImageButtonProps;
visible: boolean; visible: boolean;
@@ -329,10 +326,7 @@ function ImageActionButtons({
hasImage: boolean; hasImage: boolean;
pk: string; pk: string;
setImage: (image: string) => void; setImage: (image: string) => void;
downloadImage: () => void;
}>) { }>) {
const globalSettings = useGlobalSettingsState();
return ( return (
<> <>
{visible && ( {visible && (
@@ -363,25 +357,6 @@ function ImageActionButtons({
}} }}
/> />
)} )}
{actions.downloadImage &&
globalSettings.isSet('INVENTREE_DOWNLOAD_FROM_URL') && (
<ActionButton
icon={
<InvenTreeIcon
icon='download'
iconProps={{ color: 'white' }}
/>
}
tooltip={t`Download remote image`}
variant='outline'
size='lg'
tooltipAlignment='top'
onClick={(event: any) => {
cancelEvent(event);
downloadImage();
}}
/>
)}
{actions.uploadFile && ( {actions.uploadFile && (
<ActionButton <ActionButton
icon={ icon={
@@ -439,21 +414,6 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
const permissions = useUserState(); const permissions = useUserState();
const downloadImage = useEditApiFormModal({
url: props.apiPath,
title: t`Download Image`,
fields: {
remote_image: {}
},
timeout: 10000,
successMessage: t`Image downloaded successfully`,
onFormSuccess: (response: any) => {
if (response.image) {
setAndRefresh(response.image);
}
}
});
const hasOverlay: boolean = useMemo(() => { const hasOverlay: boolean = useMemo(() => {
return ( return (
props.imageActions?.selectExisting || props.imageActions?.selectExisting ||
@@ -473,7 +433,6 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
return ( return (
<> <>
{downloadImage.modal}
<Grid.Col span={{ base: 12, sm: 4 }}> <Grid.Col span={{ base: 12, sm: 4 }}>
<AspectRatio <AspectRatio
ref={ref} ref={ref}
@@ -502,7 +461,6 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
hasImage={!!props.src} hasImage={!!props.src}
pk={props.pk} pk={props.pk}
setImage={setAndRefresh} setImage={setAndRefresh}
downloadImage={downloadImage.open}
/> />
</Overlay> </Overlay>
)} )}
@@ -59,15 +59,7 @@ export default function SystemSettings() {
'INVENTREE_RESTRICT_ABOUT', 'INVENTREE_RESTRICT_ABOUT',
'DISPLAY_FULL_NAMES', 'DISPLAY_FULL_NAMES',
'DISPLAY_PROFILE_INFO', 'DISPLAY_PROFILE_INFO',
'WEEK_STARTS_ON' 'WEEK_STARTS_ON',
]}
/>
<GlobalSettingList
heading={t`Image Download Settings`}
keys={[
'INVENTREE_DOWNLOAD_FROM_URL',
'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE',
'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT',
'INVENTREE_STRICT_URLS' 'INVENTREE_STRICT_URLS'
]} ]}
/> />