145 lines
3.8 KiB
Python
145 lines
3.8 KiB
Python
"""File upload validation utilities"""
|
|||
|
|
import os
|
||
|
|
from werkzeug.utils import secure_filename
|
||
|
|
import clamd
|
||
|
|
from flask import current_app
|
||
|
|
|
||
|
|
|
||
|
|
# Allowed file extensions for attachments
|
||
|
|
ALLOWED_EXTENSIONS = {
|
||
|
|
'txt', 'log', 'pdf', 'png', 'jpg', 'jpeg', 'gif',
|
||
|
|
'doc', 'docx', 'xls', 'xlsx', 'csv'
|
||
|
|
}
|
||
|
|
|
||
|
|
# Maximum file size (10MB)
|
||
|
|
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||
|
|
|
||
|
|
|
||
|
|
def allowed_file(filename):
|
||
|
|
"""Check if file extension is allowed
|
||
|
|
|
||
|
|
Args:
|
||
|
|
filename: Name of the uploaded file
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
bool: True if extension is allowed, False otherwise
|
||
|
|
"""
|
||
|
|
if not filename:
|
||
|
|
return False
|
||
|
|
|
||
|
|
return '.' in filename and \
|
||
|
|
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||
|
|
|
||
|
|
|
||
|
|
def validate_file_size(file_stream):
|
||
|
|
"""Check if file size is within limits
|
||
|
|
|
||
|
|
Args:
|
||
|
|
file_stream: File stream object
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
bool: True if size is acceptable, False otherwise
|
||
|
|
"""
|
||
|
|
# Seek to end to get file size
|
||
|
|
file_stream.seek(0, os.SEEK_END)
|
||
|
|
size = file_stream.tell()
|
||
|
|
# Reset to beginning
|
||
|
|
file_stream.seek(0)
|
||
|
|
|
||
|
|
return size <= MAX_FILE_SIZE
|
||
|
|
|
||
|
|
|
||
|
|
def get_safe_filename(filename):
|
||
|
|
"""Get secure version of filename
|
||
|
|
|
||
|
|
Args:
|
||
|
|
filename: Original filename
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
str: Secure filename safe for filesystem storage
|
||
|
|
"""
|
||
|
|
return secure_filename(filename)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_file(file):
|
||
|
|
"""Validate uploaded file
|
||
|
|
|
||
|
|
Args:
|
||
|
|
file: Werkzeug FileStorage object
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
tuple: (is_valid, error_message)
|
||
|
|
is_valid: bool indicating if file is valid
|
||
|
|
error_message: str with error description or None
|
||
|
|
"""
|
||
|
|
if not file:
|
||
|
|
return False, "No file provided"
|
||
|
|
|
||
|
|
if file.filename == '':
|
||
|
|
return False, "No file selected"
|
||
|
|
|
||
|
|
if not allowed_file(file.filename):
|
||
|
|
return False, f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}"
|
||
|
|
|
||
|
|
if not validate_file_size(file.stream):
|
||
|
|
return False, f"File size exceeds maximum of {MAX_FILE_SIZE / (1024 * 1024):.0f}MB"
|
||
|
|
|
||
|
|
return True, None
|
||
|
|
|
||
|
|
|
||
|
|
def scan_file_for_viruses(file):
|
||
|
|
"""Scan file for viruses using ClamAV
|
||
|
|
|
||
|
|
Args:
|
||
|
|
file: Werkzeug FileStorage object
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
tuple: (is_clean, error_message)
|
||
|
|
is_clean: bool indicating if file is clean (True) or infected (False)
|
||
|
|
error_message: str with error description or None
|
||
|
|
"""
|
||
|
|
try:
|
||
|
|
# Connect to ClamAV daemon
|
||
|
|
clamd_socket = current_app.config.get('CLAMD_SOCKET')
|
||
|
|
|
||
|
|
if not clamd_socket:
|
||
|
|
# ClamAV not configured, skip scanning
|
||
|
|
current_app.logger.warning("ClamAV socket not configured, skipping virus scan")
|
||
|
|
return True, None
|
||
|
|
|
||
|
|
cd = clamd.ClamdUnixSocket(clamd_socket)
|
||
|
|
|
||
|
|
# Ping to check if ClamAV is available
|
||
|
|
try:
|
||
|
|
cd.ping()
|
||
|
|
except Exception as e:
|
||
|
|
current_app.logger.warning(f"ClamAV not available: {e}, skipping virus scan")
|
||
|
|
return True, None
|
||
|
|
|
||
|
|
# Read file content
|
||
|
|
file.stream.seek(0)
|
||
|
|
file_data = file.stream.read()
|
||
|
|
file.stream.seek(0) # Reset for later use
|
||
|
|
|
||
|
|
# Scan file
|
||
|
|
scan_result = cd.instream(file_data)
|
||
|
|
|
||
|
|
# Check result
|
||
|
|
if scan_result and 'stream' in scan_result:
|
||
|
|
status, virus_name = scan_result['stream']
|
||
|
|
|
||
|
|
if status == 'OK':
|
||
|
|
return True, None
|
||
|
|
elif status == 'FOUND':
|
||
|
|
return False, f"Virus detected: {virus_name}"
|
||
|
|
else:
|
||
|
|
return False, f"Scan error: {status}"
|
||
|
|
|
||
|
|
return True, None
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
current_app.logger.error(f"ClamAV scanning error: {e}")
|
||
|
|
# On error, we'll allow the file but log the error
|
||
|
|
# In production, you might want to reject files if scanning fails
|
||
|
|
return True, None
|