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:
+85
-11
@@ -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 ───
|
||||
|
||||
|
||||
Reference in New Issue
Block a user