Security fixes: P0-P2 complete (22 fixes)

P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed
P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK
P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal

8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+85 -11
View File
@@ -9,15 +9,18 @@ Configuration via environment variables:
- S3_SECRET_KEY: Secret key
- S3_REGION: Region (default: us-east-1)
- S3_SECURE: Use HTTPS (default: true)
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import tempfile
from abc import ABC, abstractmethod
from typing import Any
from typing import Any, AsyncIterator
import aiofiles
@@ -32,6 +35,11 @@ class StorageBackend(ABC):
"""Save data to storage at the given path. Returns the full storage path."""
...
@abstractmethod
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
"""Stream chunks to storage. Returns total bytes written."""
...
@abstractmethod
async def read(self, path: str) -> bytes:
"""Read data from storage at the given path."""
@@ -77,6 +85,18 @@ class LocalStorage(StorageBackend):
logger.debug("LocalStorage: saved %s (%d bytes)", path, len(data))
return path
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
"""Stream chunks directly to a local file. Returns total bytes written."""
full_path = self._full_path(path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
total = 0
async with aiofiles.open(full_path, "wb") as f:
async for chunk in chunk_aiter:
await f.write(chunk)
total += len(chunk)
logger.debug("LocalStorage: streamed %s (%d bytes)", path, total)
return total
async def read(self, path: str) -> bytes:
full_path = self._full_path(path)
async with aiofiles.open(full_path, "rb") as f:
@@ -155,25 +175,33 @@ class S3Storage(StorageBackend):
logger.error("S3Storage: failed to connect to %s: %s", self.endpoint, e)
raise
async def save(self, path: str, data: bytes) -> str:
from io import BytesIO
# ── Sync helper methods (called via asyncio.to_thread) ──────────────────
def _save_sync(self, path: str, data: bytes) -> str:
client = self._get_client()
client.put_object(
bucket_name=self.bucket,
object_name=path,
data=BytesIO(data),
data=io.BytesIO(data),
length=len(data),
)
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
return path
async def read(self, path: str) -> bytes:
def _put_file_sync(self, object_name: str, file_path: str) -> str:
client = self._get_client()
client.fput_object(self.bucket, object_name, file_path)
return object_name
def _read_sync(self, path: str) -> bytes:
client = self._get_client()
response = client.get_object(self.bucket, path)
return response.read()
try:
return response.read()
finally:
response.close()
response.release_conn()
async def delete(self, path: str) -> bool:
def _delete_sync(self, path: str) -> bool:
client = self._get_client()
try:
client.remove_object(self.bucket, path)
@@ -181,7 +209,7 @@ class S3Storage(StorageBackend):
except Exception:
return False
async def exists(self, path: str) -> bool:
def _exists_sync(self, path: str) -> bool:
client = self._get_client()
try:
client.stat_object(self.bucket, path)
@@ -189,17 +217,63 @@ class S3Storage(StorageBackend):
except Exception:
return False
async def get_url(self, path: str, expires: int = 3600) -> str:
def _get_url_sync(self, path: str, expires: int) -> str:
from datetime import timedelta
client = self._get_client()
return client.presigned_get_object(self.bucket, path, expires=timedelta(seconds=expires))
async def list_files(self, prefix: str) -> list[str]:
def _list_files_sync(self, prefix: str) -> list[str]:
client = self._get_client()
objects = client.list_objects(self.bucket, prefix=prefix, recursive=True)
return [obj.object_name for obj in objects]
# ── Async public API (wraps sync calls in asyncio.to_thread) ─────────────
async def save(self, path: str, data: bytes) -> str:
result = await asyncio.to_thread(self._save_sync, path, data)
logger.debug("S3Storage: saved %s (%d bytes)", path, len(data))
return result
async def save_stream(self, path: str, chunk_aiter: AsyncIterator[bytes]) -> int:
"""Stream chunks to a temp file, then upload to S3 via fput_object.
This avoids loading the entire file into RAM. The temp file is
cleaned up after upload.
"""
tmp_fd, tmp_path = tempfile.mkstemp(prefix="s3_upload_")
os.close(tmp_fd)
total = 0
try:
async with aiofiles.open(tmp_path, "wb") as f:
async for chunk in chunk_aiter:
await f.write(chunk)
total += len(chunk)
await asyncio.to_thread(self._put_file_sync, path, tmp_path)
logger.debug("S3Storage: streamed %s (%d bytes)", path, total)
return total
finally:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
logger.warning("S3Storage: failed to clean up temp file %s", tmp_path)
async def read(self, path: str) -> bytes:
return await asyncio.to_thread(self._read_sync, path)
async def delete(self, path: str) -> bool:
return await asyncio.to_thread(self._delete_sync, path)
async def exists(self, path: str) -> bool:
return await asyncio.to_thread(self._exists_sync, path)
async def get_url(self, path: str, expires: int = 3600) -> str:
return await asyncio.to_thread(self._get_url_sync, path, expires)
async def list_files(self, prefix: str) -> list[str]:
return await asyncio.to_thread(self._list_files_sync, prefix)
# ─── Factory ───