Enforce owner checks for upload attachments

This commit is contained in:
Duarte Antunes
2026-06-01 08:47:48 +01:00
committed by GitHub
parent 8874a11baf
commit e77d87fa80
6 changed files with 352 additions and 59 deletions

View File

@@ -8,7 +8,7 @@ import hashlib
import mimetypes
import threading
from datetime import datetime, timedelta
from typing import Dict, Any
from typing import Dict, Any, Optional
from fastapi import HTTPException, UploadFile
def secure_filename(filename: str) -> str:
"""Sanitize a filename (replaces werkzeug.utils.secure_filename)."""
@@ -225,6 +225,106 @@ class UploadHandler:
"""Validate that the upload ID matches the expected pattern."""
pattern = r'^[0-9a-fA-F]{32}\.[A-Za-z0-9]+$'
return re.fullmatch(pattern, upload_id) is not None
def _inside_upload_dir(self, path: str) -> bool:
"""Check if path is inside the upload directory."""
base = os.path.realpath(self.upload_dir)
p = os.path.realpath(path)
try:
return os.path.commonpath([base, p]) == base
except Exception:
return False
def _load_upload_index(self) -> Dict[str, Any]:
uploads_db_path = os.path.join(self.upload_dir, "uploads.json")
if not os.path.exists(uploads_db_path):
return {}
try:
with open(uploads_db_path, "r") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception as e:
logger.warning(f"Failed to read uploads database: {e}")
return {}
def get_upload_info(self, upload_id: str) -> Optional[Dict[str, Any]]:
"""Return the uploads.json metadata row for an upload ID, if present."""
if not self.validate_upload_id(upload_id):
return None
for info in self._load_upload_index().values():
if isinstance(info, dict) and info.get("id") == upload_id:
return dict(info)
return None
def _find_upload_path(self, upload_id: str) -> Optional[str]:
"""Find an upload file by ID while staying inside upload_dir."""
if not self.validate_upload_id(upload_id):
return None
direct = os.path.join(self.upload_dir, upload_id)
if os.path.exists(direct) and self._inside_upload_dir(direct):
return direct
for root, _dirs, files in os.walk(self.upload_dir, followlinks=False):
if upload_id in files:
path = os.path.join(root, upload_id)
if self._inside_upload_dir(path):
return path
return None
def resolve_upload(
self,
upload_id: str,
owner: Optional[str] = None,
auth_manager: Any = None,
allow_admin: bool = True,
) -> Optional[Dict[str, Any]]:
"""Resolve an upload ID to metadata only if the caller may read it.
This is the owner-aware lookup used by internal processors. Public
download routes already perform owner checks; chat/document paths must
do the same before reading file bytes server-side.
"""
if not self.validate_upload_id(upload_id):
logger.warning(f"Invalid upload ID format: {upload_id}")
return None
auth_configured = bool(auth_manager and getattr(auth_manager, "is_configured", False))
if auth_configured and not owner:
return None
info = self.get_upload_info(upload_id) or {}
is_admin = False
if allow_admin and owner and auth_manager and hasattr(auth_manager, "is_admin"):
try:
is_admin = bool(auth_manager.is_admin(owner))
except Exception:
is_admin = False
if owner and not is_admin:
if info.get("owner") != owner:
logger.warning("Upload %s denied for owner %s", upload_id, owner)
return None
if not owner and info.get("owner") is not None:
logger.warning("Upload %s denied without an authenticated owner", upload_id)
return None
path = info.get("path")
if not path or not os.path.exists(path) or not self._inside_upload_dir(path):
path = self._find_upload_path(upload_id)
if not path:
return None
if not self._inside_upload_dir(path):
logger.warning(f"Upload path outside upload directory: {path}")
return None
resolved = dict(info)
resolved.setdefault("id", upload_id)
resolved["path"] = path
resolved.setdefault("name", os.path.basename(path))
resolved.setdefault("original_name", resolved["name"])
resolved.setdefault("mime", mimetypes.guess_type(path)[0] or "application/octet-stream")
return resolved
def cleanup_rate_limits(self):
"""Remove stale entries from upload_rate_log."""