Django Path Traversal in FileResponse

High Risk Path Traversal
djangopythonpath-traversalfileresponsedirectory-traversalfile-download

What it is

The Django application uses FileResponse with user-controlled file paths from request data without proper validation, enabling path traversal attacks. Attackers can manipulate file paths in requests to access files outside the intended directory structure, potentially exposing sensitive system files, configuration data, database files, or other restricted content through the FileResponse mechanism.

# Vulnerable: FileResponse with user-controlled paths from django.http import FileResponse, JsonResponse, Http404 from django.views import View from django.conf import settings import os # Dangerous: Direct file serving with user input class FileDownloadView(View): def get(self, request, file_id): filename = request.GET.get('filename', '') path = request.GET.get('path', '') # CRITICAL: User controls file path if path: file_path = os.path.join(settings.MEDIA_ROOT, path, filename) else: file_path = os.path.join(settings.MEDIA_ROOT, 'downloads', filename) try: return FileResponse(open(file_path, 'rb'), as_attachment=True, filename=filename) except FileNotFoundError: raise Http404('File not found') # Another dangerous pattern def serve_user_document(request): doc_path = request.POST.get('document_path', '') # Dangerous: Direct path from request full_path = os.path.join('/var/documents', doc_path) try: response = FileResponse(open(full_path, 'rb')) response['Content-Disposition'] = f'attachment; filename="{os.path.basename(doc_path)}"' return response except Exception as e: return JsonResponse({'error': str(e)}) # Report download with path traversal def download_report(request): report_type = request.GET.get('type', '') date = request.GET.get('date', '') format_type = request.GET.get('format', 'pdf') # Dangerous: User-controlled path construction report_filename = f'{report_type}_{date}.{format_type}' report_path = os.path.join(settings.MEDIA_ROOT, 'reports', report_filename) if os.path.exists(report_path): return FileResponse(open(report_path, 'rb'), as_attachment=True) return JsonResponse({'error': 'Report not found'}) # Backup file access def download_backup(request): backup_name = request.GET.get('backup', '') directory = request.GET.get('dir', 'daily') # Dangerous: Multiple user inputs in path backup_path = f'/backups/{directory}/{backup_name}' try: return FileResponse(open(backup_path, 'rb'), as_attachment=True, filename=backup_name) except FileNotFoundError: return JsonResponse({'error': 'Backup not found'}) # Log file download def download_log(request): log_name = request.GET.get('log', '') server = request.GET.get('server', 'web') # Dangerous: Server and log name from user log_path = f'/var/log/{server}/{log_name}.log' if os.path.exists(log_path): return FileResponse(open(log_path, 'rb'), as_attachment=True) raise Http404('Log file not found') # Configuration file download def export_config(request): config_file = request.POST.get('config_file', '') environment = request.POST.get('environment', '') # Dangerous: Config path from user input config_path = os.path.join(settings.BASE_DIR, 'config', environment, config_file) try: response = FileResponse(open(config_path, 'rb')) response['Content-Type'] = 'application/octet-stream' response['Content-Disposition'] = f'attachment; filename="{config_file}"' return response except Exception: return JsonResponse({'error': 'Config export failed'})
# Secure: Safe FileResponse with validation from django.http import FileResponse, JsonResponse, Http404 from django.views import View from django.conf import settings from django.core.exceptions import ValidationError from pathlib import Path import os import re # Safe: Validated file download class SafeFileDownloadView(View): def get(self, request, file_id): try: # Validate file ID validated_file_id = self.validate_file_id(file_id) # Get file info from database or allowlist file_info = self.get_file_info(validated_file_id) # Construct safe file path safe_path = self.get_safe_file_path(file_info) # Serve file securely return self.serve_file_safely(safe_path, file_info) except ValidationError as e: return JsonResponse({'error': str(e)}, status=400) except FileNotFoundError: raise Http404('File not found') def validate_file_id(self, file_id): # Validate file ID format if not file_id or not file_id.isdigit(): raise ValidationError('Invalid file ID') file_id = int(file_id) if file_id <= 0: raise ValidationError('Invalid file ID') return file_id def get_file_info(self, file_id): # This would typically query a database # Using a mock allowlist for demonstration allowed_files = { 1: {'filename': 'document1.pdf', 'path': 'documents'}, 2: {'filename': 'report.xlsx', 'path': 'reports'}, 3: {'filename': 'image.jpg', 'path': 'images'} } if file_id not in allowed_files: raise ValidationError('File not found') return allowed_files[file_id] def get_safe_file_path(self, file_info): # Define safe base directory downloads_dir = Path(settings.MEDIA_ROOT) / 'downloads' # Construct path using validated components file_dir = downloads_dir / file_info['path'] file_path = file_dir / file_info['filename'] # Validate path is within downloads directory try: resolved_path = file_path.resolve() downloads_dir_resolved = downloads_dir.resolve() resolved_path.relative_to(downloads_dir_resolved) return resolved_path except ValueError: raise ValidationError('File path outside allowed directory') def serve_file_safely(self, file_path, file_info): try: if not file_path.exists(): raise FileNotFoundError('File not found') # Check file size max_size = 100 * 1024 * 1024 # 100MB if file_path.stat().st_size > max_size: raise ValidationError('File too large') # Serve file response = FileResponse( open(file_path, 'rb'), as_attachment=True, filename=file_info['filename'] ) return response except PermissionError: raise ValidationError('Access denied') # Safe: Document serving with validation def safe_serve_user_document(request): document_id = request.POST.get('document_id', '') try: # Validate document access validated_doc = validate_document_access(request, document_id) # Get safe file path file_path = get_safe_document_path(validated_doc) # Serve document return serve_document_safely(file_path, validated_doc) except ValidationError as e: return JsonResponse({'error': str(e)}, status=400) def validate_document_access(request, document_id): # Validate document ID if not document_id.isdigit(): raise ValidationError('Invalid document ID') document_id = int(document_id) # Check user access (mock implementation) if not request.user.is_authenticated: raise ValidationError('Authentication required') # This would typically check database permissions user_documents = [1, 2, 3] # Mock user's accessible documents if document_id not in user_documents: raise ValidationError('Access denied') return { 'id': document_id, 'filename': f'document_{document_id}.pdf', 'owner': request.user.id } def get_safe_document_path(document_info): # Define safe base directory documents_dir = Path(settings.MEDIA_ROOT) / 'user_documents' # Construct safe path file_path = documents_dir / str(document_info['owner']) / document_info['filename'] # Validate path try: resolved_path = file_path.resolve() documents_dir_resolved = documents_dir.resolve() resolved_path.relative_to(documents_dir_resolved) return resolved_path except ValueError: raise ValidationError('Document path outside allowed directory') def serve_document_safely(file_path, document_info): try: if not file_path.exists(): raise ValidationError('Document not found') response = FileResponse( open(file_path, 'rb'), as_attachment=True, filename=document_info['filename'] ) # Set security headers response['X-Content-Type-Options'] = 'nosniff' response['Content-Security-Policy'] = "default-src 'none'" return response except PermissionError: raise ValidationError('Access denied') # Safe: Report download with allowlists def safe_download_report(request): report_type = request.GET.get('type', '') date = request.GET.get('date', '') try: # Validate inputs validated_data = validate_report_request(report_type, date) # Get report file report_path = get_safe_report_path(validated_data) # Serve report return serve_report_safely(report_path, validated_data) except ValidationError as e: return JsonResponse({'error': str(e)}, status=400) def validate_report_request(report_type, date): # Validate report type allowed_reports = ['sales', 'inventory', 'users', 'analytics'] if report_type not in allowed_reports: raise ValidationError('Report type not allowed') # Validate date format if not re.match(r'^\d{4}-\d{2}-\d{2}$', date): raise ValidationError('Invalid date format') return { 'type': report_type, 'date': date, 'filename': f'{report_type}_report_{date}.pdf' } def get_safe_report_path(report_data): # Define safe reports directory reports_dir = Path(settings.MEDIA_ROOT) / 'reports' / report_data['type'] # Construct file path file_path = reports_dir / report_data['filename'] # Validate path try: resolved_path = file_path.resolve() reports_base = Path(settings.MEDIA_ROOT).resolve() / 'reports' resolved_path.relative_to(reports_base) return resolved_path except ValueError: raise ValidationError('Report path outside allowed directory') def serve_report_safely(file_path, report_data): try: if not file_path.exists(): raise ValidationError('Report not found') response = FileResponse( open(file_path, 'rb'), as_attachment=True, filename=report_data['filename'] ) response['Content-Type'] = 'application/pdf' return response except Exception: raise ValidationError('Report serving failed') # Safe: Backup access with strict controls def safe_download_backup(request): backup_id = request.GET.get('backup_id', '') try: # Only allow staff access if not request.user.is_staff: raise ValidationError('Access denied') # Validate backup access validated_backup = validate_backup_access(backup_id) # Get backup file backup_path = get_safe_backup_path(validated_backup) # Serve backup return serve_backup_safely(backup_path, validated_backup) except ValidationError as e: return JsonResponse({'error': str(e)}, status=400) def validate_backup_access(backup_id): # Validate backup ID format if not backup_id.isdigit(): raise ValidationError('Invalid backup ID') # This would query a database for valid backups valid_backups = { 1: 'backup_20231201.tar.gz', 2: 'backup_20231202.tar.gz', 3: 'backup_20231203.tar.gz' } backup_id = int(backup_id) if backup_id not in valid_backups: raise ValidationError('Backup not found') return { 'id': backup_id, 'filename': valid_backups[backup_id] } def get_safe_backup_path(backup_info): # Define safe backup directory backup_dir = Path(settings.BASE_DIR) / 'backups' # Construct file path file_path = backup_dir / backup_info['filename'] # Validate path try: resolved_path = file_path.resolve() backup_dir_resolved = backup_dir.resolve() resolved_path.relative_to(backup_dir_resolved) return resolved_path except ValueError: raise ValidationError('Backup path outside allowed directory') def serve_backup_safely(file_path, backup_info): try: if not file_path.exists(): raise ValidationError('Backup file not found') response = FileResponse( open(file_path, 'rb'), as_attachment=True, filename=backup_info['filename'] ) response['Content-Type'] = 'application/gzip' return response except Exception: raise ValidationError('Backup serving failed')

💡 Why This Fix Works

See fix suggestions for detailed explanation.

Why it happens

Django views pass user-controlled data from request parameters directly to FileResponse without validation, enabling attackers to inject '../' sequences. Example: filename = request.GET['file']; return FileResponse(open(os.path.join(MEDIA_ROOT, filename), 'rb')) allows accessing any file.

Root causes

Using FileResponse with Unvalidated User Input for File Paths

Django views pass user-controlled data from request parameters directly to FileResponse without validation, enabling attackers to inject '../' sequences. Example: filename = request.GET['file']; return FileResponse(open(os.path.join(MEDIA_ROOT, filename), 'rb')) allows accessing any file.

Missing Path Normalization Before FileResponse Creation

Applications fail to normalize paths before serving files, allowing encoded traversal sequences to bypass validation. URL-encoded '../' (%2e%2e%2f) or double-encoded variants pass checks but resolve to traversal during file access in FileResponse.

Insufficient Filtering of Directory Traversal Sequences

Simple blacklist filtering like path.replace('..', '') fails against nested patterns '..../' or '....//' where one removal leaves '../'. Incomplete regex patterns miss encoded variants, allowing traversal to FileResponse through filter evasion.

Trusting User-Provided File Paths Without Verification

Applications accept relative paths assuming safety, but os.path.join('/base', '/etc/passwd') discards base when second argument is absolute. FileResponse then serves the absolute path, bypassing directory restrictions entirely through path manipulation.

Direct Use of Request Data in File Path Construction

Views build paths using f-strings or concatenation with request data: file_path = f'{MEDIA_ROOT}/{request.GET["path"]}/{request.GET["file"]}'; FileResponse(open(file_path, 'rb')). Multiple injection points enable traversal through any unvalidated parameter.

Fixes

1

Validate and Sanitize All File Paths Before Using with FileResponse

Use regex to enforce alphanumeric filenames: re.match(r'^[a-zA-Z0-9._-]+$', filename). Validate length limits, reject '..' sequences, and check file extensions against allowlists. Apply validation at form/serializer level before constructing paths for FileResponse.

2

Use Allowlists for Permitted File Names and Directories

Map user identifiers to actual filenames: ALLOWED_FILES = {'report1': 'monthly.pdf'}; actual = ALLOWED_FILES.get(file_id). Maintain database of allowed files with access controls. Only serve files explicitly listed, never construct paths from direct user input.

3

Implement Proper Path Resolution and Boundary Checking

Use Path.resolve() to get absolute paths, then verify with relative_to(): resolved = (base / filename).resolve(); resolved.relative_to(base). This catches traversal attempts where resolved path escapes base directory before FileResponse accesses file.

4

Use Indirect File References Instead of Direct Paths

Store files with UUID identifiers in database: UploadedFile.objects.get(id=uuid, user=request.user). Users provide UUIDs, application controls actual file paths. Return FileResponse(file_obj.file.open('rb')) where path never exposed to user manipulation.

5

Employ Django's Secure File Serving Mechanisms

Use FileField with upload_to for automatic path management: file = models.FileField(upload_to='documents/%Y/%m/'). Serve via FileResponse(document.file.open('rb')) after permission checks. Configure FileSystemStorage with explicit location boundaries for additional security.

6

Normalize Paths and Verify They Stay Within Allowed Directories

Decode URL encoding, normalize Unicode, and apply os.path.normpath() before validation. Check for traversal patterns after normalization. Use Path comparison to verify resolved path within allowed base before creating FileResponse.

Detect This Vulnerability in Your Code

Sourcery automatically identifies django path traversal in fileresponse and many other security issues in your codebase.