Просмотр исходного кода

Fixes #20236: Improve file naming and upload handling (#20315)

Martin Hauser 5 месяцев назад
Родитель
Сommit
2d6b3d19e7

+ 4 - 2
netbox/extras/models/models.py

@@ -1,6 +1,6 @@
 import json
-import os
 import urllib.parse
+from pathlib import Path
 
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
@@ -728,7 +728,9 @@ class ImageAttachment(ChangeLoggedModel):
 
     @property
     def filename(self):
-        return os.path.basename(self.image.name).split('_', 2)[2]
+        base_name = Path(self.image.name).name
+        prefix = f"{self.object_type.model}_{self.object_id}_"
+        return base_name.removeprefix(prefix)
 
     @property
     def html_tag(self):

+ 85 - 7
netbox/extras/tests/test_models.py

@@ -1,17 +1,95 @@
 import tempfile
 from pathlib import Path
 
+from django.contrib.contenttypes.models import ContentType
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.forms import ValidationError
 from django.test import tag, TestCase
 
 from core.models import DataSource, ObjectType
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup
-from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, Tag
+from extras.models import ConfigContext, ConfigContextProfile, ConfigTemplate, ImageAttachment, Tag
 from tenancy.models import Tenant, TenantGroup
 from utilities.exceptions import AbortRequest
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 
 
+class ImageAttachmentTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
+        cls.image_content = b''
+
+    def _stub_image_attachment(self, object_id, image_filename, name=None):
+        """
+        Creates an instance of ImageAttachment with the provided object_id and image_name.
+
+        This method prepares a stubbed image attachment to test functionalities that
+        require an ImageAttachment object.
+        The function initializes the attachment with a specified file name and
+        pre-defined image content.
+        """
+        ia = ImageAttachment(
+            object_type=self.ct_rack,
+            object_id=object_id,
+            name=name,
+            image=SimpleUploadedFile(
+                name=image_filename,
+                content=self.image_content,
+                content_type='image/jpeg',
+            ),
+        )
+        return ia
+
+    def test_filename_strips_expected_prefix(self):
+        """
+        Tests that the filename of the image attachment is stripped of the expected
+        prefix.
+        """
+        ia = self._stub_image_attachment(12, 'image-attachments/rack_12_My_File.png')
+        self.assertEqual(ia.filename, 'My_File.png')
+
+    def test_filename_legacy_nested_path_returns_basename(self):
+        """
+        Tests if the filename of a legacy-nested path correctly returns only the basename.
+        """
+        # e.g. "image-attachments/rack_12_5/31/23.jpg" -> "23.jpg"
+        ia = self._stub_image_attachment(12, 'image-attachments/rack_12_5/31/23.jpg')
+        self.assertEqual(ia.filename, '23.jpg')
+
+    def test_filename_no_prefix_returns_basename(self):
+        """
+        Tests that the filename property correctly returns the basename for an image
+        attachment that has no leading prefix in its path.
+        """
+        ia = self._stub_image_attachment(42, 'image-attachments/just_name.webp')
+        self.assertEqual(ia.filename, 'just_name.webp')
+
+    def test_mismatched_prefix_is_not_stripped(self):
+        """
+        Tests that a mismatched prefix in the filename is not stripped.
+        """
+        # Prefix does not match object_id -> leave as-is (basename only)
+        ia = self._stub_image_attachment(12, 'image-attachments/rack_13_other.png')
+        self.assertEqual('rack_13_other.png', ia.filename)
+
+    def test_str_uses_name_when_present(self):
+        """
+        Tests that the `str` representation of the object uses the
+        `name` attribute when provided.
+        """
+        ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='Human title')
+        self.assertEqual('Human title', str(ia))
+
+    def test_str_falls_back_to_filename(self):
+        """
+        Tests that the `str` representation of the object falls back to
+        the filename if the name attribute is not set.
+        """
+        ia = self._stub_image_attachment(12, 'image-attachments/rack_12_file.png', name='')
+        self.assertEqual('file.png', str(ia))
+
+
 class TagTest(TestCase):
 
     def test_default_ordering_weight_then_name_is_set(self):
@@ -445,7 +523,7 @@ class ConfigContextTest(TestCase):
         vm1 = VirtualMachine.objects.create(name="VM 1", site=site, role=vm_role)
         vm2 = VirtualMachine.objects.create(name="VM 2", cluster=cluster, role=vm_role)
 
-        # Check that their individually-rendered config contexts are identical
+        # Check that their individually rendered config contexts are identical
         self.assertEqual(
             vm1.get_config_context(),
             vm2.get_config_context()
@@ -462,7 +540,7 @@ class ConfigContextTest(TestCase):
         """
         Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
         This is combated by appending distinct() to the config context querysets. This test creates a config
-        context assigned to two tags and ensures objects related by those same two tags result in only a single
+        context assigned to two tags and ensures objects related to those same two tags result in only a single
         config context record being returned.
 
         See https://github.com/netbox-community/netbox/issues/5314
@@ -495,14 +573,14 @@ class ConfigContextTest(TestCase):
         self.assertEqual(ConfigContext.objects.get_for_object(device).count(), 1)
         self.assertEqual(device.get_config_context(), annotated_queryset[0].get_config_context())
 
-    def test_multiple_tags_return_distinct_objects_with_seperate_config_contexts(self):
+    def test_multiple_tags_return_distinct_objects_with_separate_config_contexts(self):
         """
         Tagged items use a generic relationship, which results in duplicate rows being returned when queried.
-        This is combatted by by appending distinct() to the config context querysets. This test creates a config
-        context assigned to two tags and ensures objects related by those same two tags result in only a single
+        This is combated by appending distinct() to the config context querysets. This test creates a config
+        context assigned to two tags and ensures objects related to those same two tags result in only a single
         config context record being returned.
 
-        This test case is seperate from the above in that it deals with multiple config context objects in play.
+        This test case is separate from the above in that it deals with multiple config context objects in play.
 
         See https://github.com/netbox-community/netbox/issues/5387
         """

+ 142 - 1
netbox/extras/tests/test_utils.py

@@ -1,7 +1,10 @@
+from types import SimpleNamespace
+
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
 from extras.models import ExportTemplate
-from extras.utils import filename_from_model
+from extras.utils import filename_from_model, image_upload
 from tenancy.models import ContactGroup, TenantGroup
 from wireless.models import WirelessLANGroup
 
@@ -17,3 +20,141 @@ class FilenameFromModelTests(TestCase):
 
         for model, expected in cases:
             self.assertEqual(filename_from_model(model), expected)
+
+
+class ImageUploadTests(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        # We only need a ContentType with model="rack" for the prefix;
+        # this doesn't require creating a Rack object.
+        cls.ct_rack = ContentType.objects.get(app_label='dcim', model='rack')
+
+    def _stub_instance(self, object_id=12, name=None):
+        """
+        Creates a minimal stub for use with the `image_upload()` function.
+
+        This method generates an instance of `SimpleNamespace` containing a set
+        of attributes required to simulate the expected input for the
+        `image_upload()` method.
+        It is designed to simplify testing or processing by providing a
+        lightweight representation of an object.
+        """
+        return SimpleNamespace(object_type=self.ct_rack, object_id=object_id, name=name)
+
+    def _second_segment(self, path: str):
+        """
+        Extracts and returns the portion of the input string after the
+        first '/' character.
+        """
+        return path.split('/', 1)[1]
+
+    def test_windows_fake_path_and_extension_lowercased(self):
+        """
+        Tests handling of a Windows file path with a fake directory and extension.
+        """
+        inst = self._stub_instance(name=None)
+        path = image_upload(inst, r'C:\fake_path\MyPhoto.JPG')
+        # Base directory and single-level path
+        seg2 = self._second_segment(path)
+        self.assertTrue(path.startswith('image-attachments/rack_12_'))
+        self.assertNotIn('/', seg2, 'should not create nested directories')
+        # Extension from the uploaded file, lowercased
+        self.assertTrue(seg2.endswith('.jpg'))
+
+    def test_name_with_slashes_is_flattened_no_subdirectories(self):
+        """
+        Tests that a name with slashes is flattened and does not
+        create subdirectories.
+        """
+        inst = self._stub_instance(name='5/31/23')
+        path = image_upload(inst, 'image.png')
+        seg2 = self._second_segment(path)
+        self.assertTrue(seg2.startswith('rack_12_'))
+        self.assertNotIn('/', seg2)
+        self.assertNotIn('\\', seg2)
+        self.assertTrue(seg2.endswith('.png'))
+
+    def test_name_with_backslashes_is_flattened_no_subdirectories(self):
+        """
+        Tests that a name including backslashes is correctly flattened
+        into a single directory name without creating subdirectories.
+        """
+        inst = self._stub_instance(name=r'5\31\23')
+        path = image_upload(inst, 'image_name.png')
+
+        seg2 = self._second_segment(path)
+        self.assertTrue(seg2.startswith('rack_12_'))
+        self.assertNotIn('/', seg2)
+        self.assertNotIn('\\', seg2)
+        self.assertTrue(seg2.endswith('.png'))
+
+    def test_prefix_format_is_as_expected(self):
+        """
+        Tests the output path format generated by the `image_upload` function.
+        """
+        inst = self._stub_instance(object_id=99, name='label')
+        path = image_upload(inst, 'a.webp')
+        # The second segment must begin with "rack_99_"
+        seg2 = self._second_segment(path)
+        self.assertTrue(seg2.startswith('rack_99_'))
+        self.assertTrue(seg2.endswith('.webp'))
+
+    def test_unsupported_file_extension(self):
+        """
+        Test that when the file extension is not allowed, the extension
+        is omitted.
+        """
+        inst = self._stub_instance(name='test')
+        path = image_upload(inst, 'document.txt')
+
+        seg2 = self._second_segment(path)
+        self.assertTrue(seg2.startswith('rack_12_test'))
+        self.assertFalse(seg2.endswith('.txt'))
+        # When not allowed, no extension should be appended
+        self.assertNotRegex(seg2, r'\.txt$')
+
+    def test_instance_name_with_whitespace_and_special_chars(self):
+        """
+        Test that an instance name with leading/trailing whitespace and
+        special characters is sanitized properly.
+        """
+        # Suppose the instance name has surrounding whitespace and
+        # extra slashes.
+        inst = self._stub_instance(name='  my/complex\\name  ')
+        path = image_upload(inst, 'irrelevant.png')
+
+        # The output should be flattened and sanitized.
+        # We expect the name to be transformed into a valid filename without
+        # path separators.
+        seg2 = self._second_segment(path)
+        self.assertNotIn(' ', seg2)
+        self.assertNotIn('/', seg2)
+        self.assertNotIn('\\', seg2)
+        self.assertTrue(seg2.endswith('.png'))
+
+    def test_separator_variants_with_subTest(self):
+        """
+        Tests that both forward slash and backslash in file paths are
+        handled consistently by the `image_upload` function and
+        processed into a sanitized uniform format.
+        """
+        for name in ['2025/09/12', r'2025\09\12']:
+            with self.subTest(name=name):
+                inst = self._stub_instance(name=name)
+                path = image_upload(inst, 'x.jpeg')
+                seg2 = self._second_segment(path)
+                self.assertTrue(seg2.startswith('rack_12_'))
+                self.assertNotIn('/', seg2)
+                self.assertNotIn('\\', seg2)
+                self.assertTrue(seg2.endswith('.jpeg') or seg2.endswith('.jpg'))
+
+    def test_fallback_on_suspicious_file_operation(self):
+        """
+        Test that when default_storage.get_valid_name() raises a
+        SuspiciousFileOperation, the fallback default is used.
+        """
+        inst = self._stub_instance(name=' ')
+        path = image_upload(inst, 'sample.png')
+        # Expect the fallback name 'unnamed' to be used.
+        self.assertIn('unnamed', path)
+        self.assertTrue(path.startswith('image-attachments/rack_12_'))

+ 42 - 12
netbox/extras/utils.py

@@ -1,15 +1,20 @@
 import importlib
+from pathlib import Path
 
-from django.core.exceptions import ImproperlyConfigured
+from django.core.exceptions import ImproperlyConfigured, SuspiciousFileOperation
+from django.core.files.storage import default_storage
+from django.core.files.utils import validate_file_name
 from django.db import models
 from django.db.models import Q
 from taggit.managers import _TaggableManager
 
 from netbox.context import current_request
+
 from .validators import CustomValidator
 
 __all__ = (
     'SharedObjectViewMixin',
+    'filename_from_model',
     'image_upload',
     'is_report',
     'is_script',
@@ -35,13 +40,13 @@ class SharedObjectViewMixin:
 
 
 def filename_from_model(model: models.Model) -> str:
-    """Standardises how we generate filenames from model class for exports"""
+    """Standardizes how we generate filenames from model class for exports"""
     base = model._meta.verbose_name_plural.lower().replace(' ', '_')
     return f'netbox_{base}'
 
 
 def filename_from_object(context: dict) -> str:
-    """Standardises how we generate filenames from model class for exports"""
+    """Standardizes how we generate filenames from model class for exports"""
     if 'device' in context:
         base = f"{context['device'].name or 'config'}"
     elif 'virtualmachine' in context:
@@ -64,17 +69,42 @@ def is_taggable(obj):
 def image_upload(instance, filename):
     """
     Return a path for uploading image attachments.
+
+    - Normalizes browser paths (e.g., C:\\fake_path\\photo.jpg)
+    - Uses the instance.name if provided (sanitized to a *basename*, no ext)
+    - Prefixes with a machine-friendly identifier
+
+    Note: Relies on Django's default_storage utility.
     """
-    path = 'image-attachments/'
+    upload_dir = 'image-attachments'
+    default_filename = 'unnamed'
+    allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
+
+    # Normalize Windows paths and create a Path object.
+    normalized_filename = str(filename).replace('\\', '/')
+    file_path = Path(normalized_filename)
+
+    # Extract the extension from the uploaded file.
+    ext = file_path.suffix.lower().lstrip('.')
+
+    # Use the instance-provided name if available; otherwise use the file stem.
+    # Rely on Django's get_valid_filename to perform sanitization.
+    stem = (instance.name or file_path.stem).strip()
+    try:
+        safe_stem = default_storage.get_valid_name(stem)
+    except SuspiciousFileOperation:
+        safe_stem = default_filename
+
+    # Append the uploaded extension only if it's an allowed image type
+    final_name = f"{safe_stem}.{ext}" if ext in allowed_img_extensions else safe_stem
 
-    # Rename the file to the provided name, if any. Attempt to preserve the file extension.
-    extension = filename.rsplit('.')[-1].lower()
-    if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']:
-        filename = '.'.join([instance.name, extension])
-    elif instance.name:
-        filename = instance.name
+    # Create a machine-friendly prefix from the instance
+    prefix = f"{instance.object_type.model}_{instance.object_id}"
+    name_with_path = f"{upload_dir}/{prefix}_{final_name}"
 
-    return '{}{}_{}_{}'.format(path, instance.object_type.name, instance.object_id, filename)
+    # Validate the generated relative path (blocks absolute/traversal)
+    validate_file_name(name_with_path, allow_relative_path=True)
+    return name_with_path
 
 
 def is_script(obj):
@@ -107,7 +137,7 @@ def run_validators(instance, validators):
     request = current_request.get()
     for validator in validators:
 
-        # Loading a validator class by dotted path
+        # Loading a validator class by a dotted path
         if type(validator) is str:
             module, cls = validator.rsplit('.', 1)
             validator = getattr(importlib.import_module(module), cls)()