11import importlib
2+ from pathlib import Path
23
3- from django .core .exceptions import ImproperlyConfigured
4+ from django .core .exceptions import ImproperlyConfigured , SuspiciousFileOperation
5+ from django .core .files .storage import default_storage
6+ from django .core .files .utils import validate_file_name
47from django .db import models
58from django .db .models import Q
69from taggit .managers import _TaggableManager
710
811from netbox .context import current_request
12+
913from .validators import CustomValidator
1014
1115__all__ = (
@@ -35,13 +39,13 @@ def get_queryset(self, request):
3539
3640
3741def filename_from_model (model : models .Model ) -> str :
38- """Standardises how we generate filenames from model class for exports"""
42+ """Standardizes how we generate filenames from model class for exports"""
3943 base = model ._meta .verbose_name_plural .lower ().replace (' ' , '_' )
4044 return f'netbox_{ base } '
4145
4246
4347def filename_from_object (context : dict ) -> str :
44- """Standardises how we generate filenames from model class for exports"""
48+ """Standardizes how we generate filenames from model class for exports"""
4549 if 'device' in context :
4650 base = f"{ context ['device' ].name or 'config' } "
4751 elif 'virtualmachine' in context :
@@ -64,17 +68,42 @@ def is_taggable(obj):
6468def image_upload (instance , filename ):
6569 """
6670 Return a path for uploading image attachments.
71+
72+ - Normalizes browser paths (e.g., C:\\ fake_path\\ photo.jpg)
73+ - Uses the instance.name if provided (sanitized to a *basename*, no ext)
74+ - Prefixes with a machine-friendly identifier
75+
76+ Note: Relies on Django's default_storage utility.
6777 """
68- path = 'image-attachments/'
78+ upload_dir = 'image-attachments'
79+ default_filename = 'unnamed'
80+ allowed_img_extensions = ('bmp' , 'gif' , 'jpeg' , 'jpg' , 'png' , 'webp' )
81+
82+ # Normalize Windows paths and create a Path object.
83+ normalized_filename = str (filename ).replace ('\\ ' , '/' )
84+ file_path = Path (normalized_filename )
85+
86+ # Extract the extension from the uploaded file.
87+ ext = file_path .suffix .lower ().lstrip ('.' )
88+
89+ # Use the instance-provided name if available; otherwise use the file stem.
90+ # Rely on Django's get_valid_filename to perform sanitization.
91+ stem = (instance .name or file_path .stem ).strip ()
92+ try :
93+ safe_stem = default_storage .get_valid_name (stem )
94+ except SuspiciousFileOperation :
95+ safe_stem = default_filename
96+
97+ # Append the uploaded extension only if it's an allowed image type
98+ final_name = f"{ safe_stem } .{ ext } " if ext in allowed_img_extensions else safe_stem
6999
70- # Rename the file to the provided name, if any. Attempt to preserve the file extension.
71- extension = filename .rsplit ('.' )[- 1 ].lower ()
72- if instance .name and extension in ['bmp' , 'gif' , 'jpeg' , 'jpg' , 'png' , 'webp' ]:
73- filename = '.' .join ([instance .name , extension ])
74- elif instance .name :
75- filename = instance .name
100+ # Create a machine-friendly prefix from the instance
101+ prefix = f"{ instance .object_type .model } _{ instance .object_id } "
102+ name_with_path = f"{ upload_dir } /{ prefix } _{ final_name } "
76103
77- return '{}{}_{}_{}' .format (path , instance .object_type .name , instance .object_id , filename )
104+ # Validate the generated relative path (blocks absolute/traversal)
105+ validate_file_name (name_with_path , allow_relative_path = True )
106+ return name_with_path
78107
79108
80109def is_script (obj ):
@@ -107,7 +136,7 @@ def run_validators(instance, validators):
107136 request = current_request .get ()
108137 for validator in validators :
109138
110- # Loading a validator class by dotted path
139+ # Loading a validator class by a dotted path
111140 if type (validator ) is str :
112141 module , cls = validator .rsplit ('.' , 1 )
113142 validator = getattr (importlib .import_module (module ), cls )()
0 commit comments