Skip to content

Commit 62f9ae9

Browse files
committed
fix(extras): Improve file naming and upload handling
Refines logic for generating filenames and paths for file uploads. Uses `Path` for cross-platform compatibility, adds sanitization, and validates file paths to prevent security risks. Enhances support for custom instance names and preserves specified file extensions. Fixes #20236
1 parent bf73564 commit 62f9ae9

File tree

2 files changed

+45
-14
lines changed

2 files changed

+45
-14
lines changed

netbox/extras/models/models.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import json
2-
import os
32
import urllib.parse
3+
from pathlib import Path
44

55
from django.conf import settings
66
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
@@ -728,7 +728,9 @@ def delete(self, *args, **kwargs):
728728

729729
@property
730730
def filename(self):
731-
return os.path.basename(self.image.name).split('_', 2)[2]
731+
base_name = Path(self.image.name).name
732+
prefix = f"{self.object_type.model}_{self.object_id}_"
733+
return base_name.removeprefix(prefix)
732734

733735
@property
734736
def html_tag(self):

netbox/extras/utils.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import 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
47
from django.db import models
58
from django.db.models import Q
69
from taggit.managers import _TaggableManager
710

811
from netbox.context import current_request
12+
913
from .validators import CustomValidator
1014

1115
__all__ = (
@@ -35,13 +39,13 @@ def get_queryset(self, request):
3539

3640

3741
def 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

4347
def 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):
6468
def 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

80109
def 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

Comments
 (0)