Files

384 lines
15 KiB
Python
Raw Permalink Normal View History

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import base64
import os
import uuid
from typing import Dict, Optional, List, Set
from app.core.config import settings
from app.core.s3 import upload_file_to_s3
from app.services.file_storage import resolve_public_url
from app.core.logger import get_logger
logger = get_logger(__name__)
# Allowed file types and size limits
ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
ALLOWED_DOCUMENT_TYPES = {'application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv', 'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}
ALLOWED_FILE_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_DOCUMENT_TYPES
# Security: SVG removed from default allowed types due to XSS risks
# Can be enabled per-agent if needed with proper sanitization
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
# Magic byte signatures for file type validation
# This prevents MIME type spoofing attacks
MAGIC_BYTES = {
# Images
'image/jpeg': [
(b'\xff\xd8\xff\xe0', 0), # JPEG JFIF
(b'\xff\xd8\xff\xe1', 0), # JPEG EXIF
(b'\xff\xd8\xff\xe2', 0), # JPEG ICC
(b'\xff\xd8\xff\xe8', 0), # JPEG SPIFF
(b'\xff\xd8\xff\xdb', 0), # JPEG DQT
(b'\xff\xd8\xff\xee', 0), # JPEG Adobe
],
'image/jpg': [
(b'\xff\xd8\xff\xe0', 0),
(b'\xff\xd8\xff\xe1', 0),
(b'\xff\xd8\xff\xe2', 0),
(b'\xff\xd8\xff\xe8', 0),
(b'\xff\xd8\xff\xdb', 0),
(b'\xff\xd8\xff\xee', 0),
],
'image/png': [
(b'\x89PNG\r\n\x1a\n', 0),
],
'image/gif': [
(b'GIF87a', 0),
(b'GIF89a', 0),
],
'image/webp': [
(b'RIFF', 0), # WebP starts with RIFF, then has WEBP at offset 8
],
# Documents
'application/pdf': [
(b'%PDF-', 0),
],
'application/msword': [
(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1', 0), # OLE Compound File (DOC)
],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
(b'PK\x03\x04', 0), # ZIP-based (DOCX)
],
'application/vnd.ms-excel': [
(b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1', 0), # OLE Compound File (XLS)
],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
(b'PK\x03\x04', 0), # ZIP-based (XLSX)
],
'text/plain': [], # Text files don't have magic bytes, validated by content
'text/csv': [], # CSV files don't have magic bytes, validated by content
}
# File extension to MIME type mapping for validation
EXTENSION_TO_MIME = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}
# Category labels for display
FILE_TYPE_CATEGORIES = {
'images': {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'},
'documents': {'application/pdf'},
'office': {'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'},
'text': {'text/plain', 'text/csv'},
}
class FileUploadService:
"""Service for handling file uploads to S3 or local storage with enhanced security"""
@staticmethod
def validate_magic_bytes(file_content: bytes, claimed_content_type: str) -> tuple[bool, Optional[str]]:
"""
Validate file content against magic byte signatures to prevent MIME type spoofing.
Args:
file_content: The raw binary content of the file
claimed_content_type: The MIME type claimed by the client
Returns:
(is_valid, error_message)
"""
if claimed_content_type not in MAGIC_BYTES:
logger.warning(f"Unknown content type for magic byte validation: {claimed_content_type}")
return False, f"File type '{claimed_content_type}' is not allowed"
signatures = MAGIC_BYTES[claimed_content_type]
# Text files don't have magic bytes - validate as text content
if not signatures:
if claimed_content_type in {'text/plain', 'text/csv'}:
# Check if content is valid text (not binary)
try:
# Try to decode as UTF-8, allowing for some common encodings
sample = file_content[:8192] # Check first 8KB
sample.decode('utf-8')
return True, None
except UnicodeDecodeError:
try:
sample.decode('latin-1')
return True, None
except UnicodeDecodeError:
return False, "File content does not appear to be valid text"
return True, None
# Check for WebP special case (has WEBP at offset 8)
if claimed_content_type == 'image/webp':
if len(file_content) >= 12:
if file_content[:4] == b'RIFF' and file_content[8:12] == b'WEBP':
return True, None
return False, "File content does not match WebP format signature"
# Check against all possible signatures for this MIME type
for signature, offset in signatures:
end_offset = offset + len(signature)
if len(file_content) >= end_offset:
if file_content[offset:end_offset] == signature:
return True, None
return False, f"File content does not match the claimed type '{claimed_content_type}'. The file may be corrupted or mislabeled."
@staticmethod
def validate_extension(filename: str, claimed_content_type: str) -> tuple[bool, Optional[str]]:
"""
Validate that file extension matches the claimed content type.
Args:
filename: The name of the file
claimed_content_type: The MIME type claimed by the client
Returns:
(is_valid, error_message)
"""
ext = os.path.splitext(filename.lower())[1]
if not ext:
return False, "File must have an extension"
if ext not in EXTENSION_TO_MIME:
return False, f"File extension '{ext}' is not allowed. Allowed extensions: {', '.join(sorted(EXTENSION_TO_MIME.keys()))}"
expected_mime = EXTENSION_TO_MIME[ext]
# Handle jpg/jpeg equivalence
if claimed_content_type in {'image/jpeg', 'image/jpg'} and expected_mime in {'image/jpeg', 'image/jpg'}:
return True, None
if expected_mime != claimed_content_type:
return False, f"File extension '{ext}' does not match content type '{claimed_content_type}'"
return True, None
@staticmethod
def validate_file_type(content_type: str, allowed_types: Optional[Set[str]] = None) -> bool:
"""
Validate if the file type is allowed.
Args:
content_type: The MIME type to validate
allowed_types: Optional set of allowed MIME types. If None, uses default ALLOWED_FILE_TYPES.
"""
types_to_check = allowed_types if allowed_types is not None else ALLOWED_FILE_TYPES
return content_type in types_to_check
@staticmethod
def get_allowed_types_for_agent(agent_allowed_types: Optional[List[str]] = None) -> Set[str]:
"""
Get the set of allowed file types for an agent.
Args:
agent_allowed_types: List of category names like ['images', 'documents', 'office', 'text']
If None or empty, returns all default allowed types.
Returns:
Set of MIME types that are allowed
"""
if not agent_allowed_types:
return ALLOWED_FILE_TYPES
allowed = set()
for category in agent_allowed_types:
category_lower = category.lower()
if category_lower in FILE_TYPE_CATEGORIES:
allowed.update(FILE_TYPE_CATEGORIES[category_lower])
# If no valid categories found, return default
if not allowed:
return ALLOWED_FILE_TYPES
return allowed
@staticmethod
def validate_file_size(file_size: int, content_type: str) -> tuple[bool, Optional[str]]:
"""
Validate file size based on content type
Returns: (is_valid, error_message)
"""
if content_type in ALLOWED_IMAGE_TYPES and file_size > MAX_IMAGE_SIZE:
return False, f"Image file size exceeds maximum allowed size of {MAX_IMAGE_SIZE / (1024*1024):.1f}MB"
if file_size > MAX_FILE_SIZE:
return False, f"File size exceeds maximum allowed size of {MAX_FILE_SIZE / (1024*1024):.1f}MB"
return True, None
@staticmethod
def get_friendly_allowed_types_message(allowed_types: Optional[Set[str]] = None) -> str:
"""Generate a user-friendly message listing allowed file types."""
types_to_use = allowed_types if allowed_types is not None else ALLOWED_FILE_TYPES
extensions = []
for ext, mime in EXTENSION_TO_MIME.items():
if mime in types_to_use:
extensions.append(ext.upper().replace('.', ''))
return ', '.join(sorted(set(extensions)))
@staticmethod
async def upload_file(
file_data: dict,
org_id: str,
customer_id: Optional[str] = None,
user_id: Optional[str] = None,
allowed_types: Optional[Set[str]] = None
) -> Dict[str, any]:
"""
Upload a file to S3 or local storage with enhanced security validation.
Args:
file_data: Dict with 'content' (base64), 'filename', 'content_type', 'size'
org_id: Organization ID
customer_id: Optional customer ID (for widget users)
user_id: Optional user ID (for authenticated users)
allowed_types: Optional set of allowed MIME types for this upload
Returns:
Dict with 'file_url', 'signed_url', 'filename', 'content_type', 'size'
Raises:
ValueError: If validation fails or upload fails
"""
try:
filename = file_data.get('filename', '')
content_type = file_data.get('content_type', 'application/octet-stream')
# Validate file data
if not file_data.get('content') or not filename:
raise ValueError("Missing required file data: content and filename")
# Determine which types are allowed
types_allowed = allowed_types if allowed_types is not None else ALLOWED_FILE_TYPES
friendly_types = FileUploadService.get_friendly_allowed_types_message(types_allowed)
# Step 1: Validate extension matches claimed content type
is_valid, error = FileUploadService.validate_extension(filename, content_type)
if not is_valid:
logger.warning(f"Extension validation failed for {filename}: {error}")
raise ValueError(error)
# Step 2: Validate file type is allowed
if not FileUploadService.validate_file_type(content_type, types_allowed):
raise ValueError(f"File type '{content_type}' is not allowed. Allowed types: {friendly_types}")
# Step 3: Decode base64 content
try:
file_content = base64.b64decode(file_data['content'])
except Exception as e:
logger.error(f"Error decoding base64 content: {str(e)}")
raise ValueError("Invalid file content encoding")
file_size = len(file_content)
# Step 4: Validate file size
is_valid, error_message = FileUploadService.validate_file_size(file_size, content_type)
if not is_valid:
raise ValueError(error_message)
# Step 5: SECURITY - Validate magic bytes to prevent MIME spoofing
is_valid, magic_error = FileUploadService.validate_magic_bytes(file_content, content_type)
if not is_valid:
logger.warning(f"Magic byte validation failed for {filename}: {magic_error}")
raise ValueError(f"Security check failed: {magic_error}")
logger.info(f"File validation passed for {filename}: type={content_type}, size={file_size}")
# Generate unique filename with sanitized extension
file_extension = os.path.splitext(filename)[1].lower()
# Additional sanitization: only allow known extensions
if file_extension not in EXTENSION_TO_MIME:
raise ValueError(f"File extension '{file_extension}' is not allowed")
unique_filename = f"{uuid.uuid4()}{file_extension}"
# Determine folder based on auth type
folder = f"chat_attachments/{org_id}"
# Upload to S3 if enabled, otherwise save locally
if settings.S3_FILE_STORAGE:
file_url = await upload_file_to_s3(
file_content=file_content,
folder=folder,
filename=unique_filename,
content_type=content_type
)
else:
# Save locally
upload_dir = os.path.join("uploads", folder)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, unique_filename)
with open(file_path, "wb") as f:
f.write(file_content)
file_url = f"/uploads/{folder}/{unique_filename}"
signed_url = await resolve_public_url(file_url)
logger.info(f"Successfully uploaded file: {filename} -> {file_url}")
return {
'file_url': file_url,
'signed_url': signed_url,
'filename': filename,
'content_type': content_type,
'size': file_size
}
except ValueError:
# Re-raise validation errors
raise
except Exception as e:
logger.error(f"Error in FileUploadService.upload_file: {str(e)}", exc_info=True)
raise ValueError(f"Failed to upload file: {str(e)}")