Phase 1: Fix all critical release blockers (B1-B10)
B1: Remove duplicate get_redis() — singleton no longer overwritten B2: Plugin routes now enforce activation status via require_active_plugin() B3: Fix UploadFile ForwardRef error — remove functools.wraps from wrap_plugin_route B4: DMS upload uses true streaming via save_stream() instead of RAM accumulation B5: Worker on_startup registers plugin event handlers + webhook dispatcher B6: Implement send_password_reset_email job, remove raw token logging B7: Webhook SSRF protection (IP validation, no redirects), secret removed from response B8: RLS repair migration 0044 + separate crm_runtime DB user (NOSUPERUSER, NOBYPASSRLS) B9: Fix .env.docker.example AUTH_SECRET → SECRET_KEY B10: Remove Redis default password, remove exposed DB/Redis ports Also: add frontend_url to config, add SMTP settings to .env.docker.example, update prestart.sh to use MIGRATION_DATABASE_URL for alembic.
This commit is contained in:
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# Frontend URL for email links (password reset, invitations, etc.)
|
||||
frontend_url: str = "http://localhost:5173"
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_login_max: int = 5
|
||||
rate_limit_login_window: int = 900 # 15 min
|
||||
|
||||
@@ -91,11 +91,6 @@ def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_redis() -> aioredis.Redis:
|
||||
"""Get a Redis client instance."""
|
||||
return aioredis.from_url(get_settings().redis_url, decode_responses=True)
|
||||
|
||||
|
||||
async def create_session(
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
|
||||
@@ -80,3 +80,72 @@ async def get_job_status(job_id: str) -> dict[str, Any] | None:
|
||||
"start_time": job_info.start_time.isoformat() if job_info.start_time else None,
|
||||
"finish_time": job_info.finish_time.isoformat() if job_info.finish_time else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Password Reset Email Job ─────────────────────────────────────────────────
|
||||
|
||||
async def send_password_reset_email(
|
||||
ctx: dict[str, Any],
|
||||
*,
|
||||
user_id: str,
|
||||
email: str,
|
||||
raw_token: str,
|
||||
expires_at: str,
|
||||
) -> None:
|
||||
"""Send a password reset email via SMTP.
|
||||
|
||||
This is an ARQ worker function. It is registered with the job registry
|
||||
so the worker can execute it when the auth service enqueues it.
|
||||
"""
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Build the reset URL
|
||||
reset_url = f"{settings.frontend_url.rstrip('/')}/reset-password?token={raw_token}"
|
||||
|
||||
# Build the email
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = settings.smtp_from_email
|
||||
msg["To"] = email
|
||||
msg["Subject"] = "LeoCRM — Passwort zurücksetzen"
|
||||
|
||||
text_body = (
|
||||
f"Sie haben angefordert, Ihr Passwort zurückzusetzen.\n\n"
|
||||
f"Klicken Sie auf den folgenden Link, um ein neues Passwort zu setzen:\n"
|
||||
f"{reset_url}\n\n"
|
||||
f"Dieser Link ist gültig bis {expires_at}.\n\n"
|
||||
f"Falls Sie diese Anfrage nicht gestellt haben, können Sie diese\n"
|
||||
f"E-Mail ignorieren. Ihr Passwort bleibt unverändert.\n"
|
||||
)
|
||||
html_body = (
|
||||
f"<html><body>"
|
||||
f"<h2>Passwort zurücksetzen</h2>"
|
||||
f"<p>Sie haben angefordert, Ihr Passwort zurückzusetzen.</p>"
|
||||
f"<p><a href=\"{reset_url}\">Passwort jetzt zurücksetzen</a></p>"
|
||||
f"<p>Dieser Link ist gültig bis {expires_at}.</p>"
|
||||
f"<p>Falls Sie diese Anfrage nicht gestellt haben, können Sie diese "
|
||||
f"E-Mail ignorieren. Ihr Passwort bleibt unverändert.</p>"
|
||||
f"</body></html>"
|
||||
)
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# Send via SMTP
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=settings.smtp_password,
|
||||
start_tls=settings.smtp_use_tls,
|
||||
)
|
||||
logger.info("Password reset email sent to %s for user %s", email, user_id)
|
||||
|
||||
|
||||
# Register the job so the worker can find it
|
||||
from app.core.job_registry import register_job # noqa: E402
|
||||
|
||||
register_job("send_password_reset_email", send_password_reset_email)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"""Plugin error isolation wrapper."""
|
||||
import logging
|
||||
import functools
|
||||
from fastapi import UploadFile as _UploadFile # noqa: F401 — needed for ForwardRef resolution
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def wrap_plugin_route(handler):
|
||||
"""Decorator that isolates plugin route errors and returns structured JSON."""
|
||||
@functools.wraps(handler)
|
||||
"""Decorator that isolates plugin route errors and returns structured JSON.
|
||||
|
||||
Does NOT use functools.wraps to avoid copying __annotations__ and
|
||||
__wrapped__ — FastAPI would otherwise try to resolve
|
||||
``ForwardRef('UploadFile')`` from the original handler's signature.
|
||||
"""
|
||||
async def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return await handler(*args, **kwargs)
|
||||
@@ -18,4 +23,8 @@ def wrap_plugin_route(handler):
|
||||
status_code=500,
|
||||
content={'detail': f'Plugin error: {exc}', 'code': 'plugin_error'}
|
||||
)
|
||||
# Preserve identity for debugging but NOT __wrapped__ or __annotations__
|
||||
wrapper.__name__ = getattr(handler, '__name__', 'wrapper')
|
||||
wrapper.__module__ = getattr(handler, '__module__', __name__)
|
||||
wrapper.__qualname__ = getattr(handler, '__qualname__', 'wrapper')
|
||||
return wrapper
|
||||
|
||||
+52
-2
@@ -90,11 +90,59 @@ def _get_redis_settings() -> RedisSettings:
|
||||
async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
"""Called when worker starts."""
|
||||
logger.info("ARQ worker starting...")
|
||||
|
||||
# Initialize Redis singleton (same as API lifespan)
|
||||
from app.core.auth import init_redis
|
||||
await init_redis()
|
||||
|
||||
# Initialize service container
|
||||
from app.core.service_container import get_container
|
||||
container = get_container()
|
||||
await container.initialize()
|
||||
|
||||
# Initialize plugin registry and discover built-in plugins
|
||||
from app.plugins.registry import get_registry
|
||||
from app.core.db import get_engine
|
||||
from app.core.event_bus import get_event_bus
|
||||
from app.core.webhook_dispatcher import register_webhook_event_handlers
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.plugin import Plugin as PluginModel
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
registry = get_registry()
|
||||
registry.initialize(get_engine(), app=None)
|
||||
registry.discover_builtins()
|
||||
|
||||
event_bus = get_event_bus()
|
||||
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||
|
||||
# Activate plugins that are marked active in DB (register event handlers)
|
||||
async with async_session() as db:
|
||||
for name in registry.resolve_load_order():
|
||||
plugin = registry.get_plugin(name)
|
||||
if plugin is None:
|
||||
continue
|
||||
result = await db.execute(
|
||||
sa_select(PluginModel).where(PluginModel.name == name)
|
||||
)
|
||||
plugin_record = result.scalar_one_or_none()
|
||||
if plugin_record is None or not plugin_record.active:
|
||||
continue
|
||||
try:
|
||||
await plugin.on_activate(db, container, event_bus)
|
||||
logger.info(f"Worker: activated plugin {name}")
|
||||
except Exception as exc:
|
||||
logger.error(f"Worker: failed to activate plugin {name}: {exc}")
|
||||
await db.commit()
|
||||
|
||||
# Register webhook dispatcher on the event bus
|
||||
register_webhook_event_handlers(event_bus)
|
||||
logger.info("Worker: webhook event handlers registered")
|
||||
|
||||
# Register search providers (normally done by app startup)
|
||||
try:
|
||||
from app.core.db import get_session_factory
|
||||
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
|
||||
factory = get_session_factory()
|
||||
factory = async_session
|
||||
async with factory() as db:
|
||||
await auto_register_providers(db)
|
||||
logger.info("Search providers registered for worker")
|
||||
@@ -105,6 +153,8 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
||||
async def on_shutdown(ctx: dict[str, Any]) -> None:
|
||||
"""Called when worker shuts down."""
|
||||
logger.info("ARQ worker shutting down...")
|
||||
from app.core.auth import close_redis
|
||||
await close_redis()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+31
@@ -224,3 +224,34 @@ async def get_current_user_id(
|
||||
) -> uuid.UUID:
|
||||
"""Extract user_id from current user session."""
|
||||
return uuid.UUID(current_user["user_id"])
|
||||
|
||||
|
||||
def require_active_plugin(plugin_name: str):
|
||||
"""FastAPI dependency factory: require that a plugin is active.
|
||||
|
||||
Returns 403 if the plugin is not active in the permission registry.
|
||||
This allows routes to be registered at app creation time while
|
||||
enforcing activation status at request time.
|
||||
"""
|
||||
async def _check(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
try:
|
||||
registry = get_permission_registry()
|
||||
if not registry.is_plugin_active(plugin_name):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"detail": f"Plugin '{plugin_name}' is not active",
|
||||
"code": "plugin_inactive",
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# If registry not initialized yet, allow request (startup race)
|
||||
pass
|
||||
return current_user
|
||||
|
||||
return _check
|
||||
|
||||
+15
-5
@@ -6,7 +6,7 @@ import time
|
||||
import traceback
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, HTTPException, Request, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -208,6 +208,11 @@ async def lifespan(app: FastAPI):
|
||||
init_permission_registry(active_plugin_names)
|
||||
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
|
||||
|
||||
# Register webhook dispatcher on the event bus
|
||||
from app.core.webhook_dispatcher import register_webhook_event_handlers
|
||||
register_webhook_event_handlers(event_bus)
|
||||
logger.info("Webhook event handlers registered")
|
||||
|
||||
# Register field definitions from active plugins only
|
||||
from app.core.permission_registry import get_permission_registry
|
||||
for name in active_plugin_names:
|
||||
@@ -368,9 +373,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(errors.router)
|
||||
|
||||
# ── Register plugin routes for all built-in plugins ──
|
||||
# Routes are registered here (before app start); activation status
|
||||
# is enforced at runtime via require_permission and plugin checks.
|
||||
# Routes are registered at app creation time so OpenAPI docs are complete.
|
||||
# Activation status is enforced per-request via require_active_plugin().
|
||||
import importlib
|
||||
from app.deps import require_active_plugin
|
||||
# Discover all built-in plugin modules and register their routes
|
||||
plugin_modules = [
|
||||
"app.plugins.builtins.tags",
|
||||
@@ -399,6 +405,7 @@ def create_app() -> FastAPI:
|
||||
for attr_name in dir(mod):
|
||||
attr = getattr(mod, attr_name)
|
||||
if isinstance(attr, type) and hasattr(attr, "manifest") and hasattr(attr.manifest, "routes"):
|
||||
plugin_name = getattr(attr.manifest, "name", mod_name.split(".")[-1])
|
||||
for route_def in attr.manifest.routes:
|
||||
try:
|
||||
router_module = importlib.import_module(route_def.module)
|
||||
@@ -407,13 +414,16 @@ def create_app() -> FastAPI:
|
||||
for route in router.routes:
|
||||
if hasattr(route, 'endpoint'):
|
||||
route.endpoint = wrap_plugin_route(route.endpoint)
|
||||
app.include_router(router)
|
||||
# Add active-plugin check as a router-level dependency
|
||||
app.include_router(
|
||||
router,
|
||||
dependencies=[Depends(require_active_plugin(plugin_name))],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
|
||||
break
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to register plugin routes for {mod_name}: {exc}")
|
||||
# Do NOT register plugin routes here — lifespan() handles it for active plugins only
|
||||
|
||||
# ── Serve frontend static files (SPA) ──────────────────────────────
|
||||
# Mount built frontend assets (JS, CSS, images)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, String, Text
|
||||
from sqlalchemy import BigInteger, DateTime, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -39,7 +39,7 @@ class Backup(Base, TenantMixin):
|
||||
PGUUID(as_uuid=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=datetime.utcnow
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, default=None
|
||||
|
||||
@@ -35,7 +35,7 @@ class Notification(Base, TenantMixin):
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -83,5 +83,5 @@ class NotificationPreference(Base, TenantMixin):
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
type_key: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
type_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -51,5 +52,4 @@ class SystemSettings(Base, TenantMixin):
|
||||
theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter")
|
||||
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
|
||||
# Automation plugin settings (JSONB)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Index, Numeric, String
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
@@ -25,6 +26,6 @@ class TaxRate(Base, TenantMixin):
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||
rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
|
||||
@@ -41,3 +41,8 @@ class Webhook(Base, TenantMixin):
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=True
|
||||
)
|
||||
|
||||
@property
|
||||
def has_secret(self) -> bool:
|
||||
"""Return True if a webhook secret is set (never expose the secret itself)."""
|
||||
return self.secret is not None and len(self.secret) > 0
|
||||
|
||||
@@ -698,7 +698,7 @@ ATTACHMENT_DIR = Path(os.environ.get("STORAGE_PATH", "/data/storage")) / "ai_att
|
||||
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25MB
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/attachments", dependencies=[Depends(require_permission("ai:write"))])
|
||||
@router.post("/sessions/{session_id}/attachments", response_model=None, dependencies=[Depends(require_permission("ai:write"))])
|
||||
async def upload_attachment(
|
||||
session_id: str,
|
||||
file: UploadFile,
|
||||
|
||||
@@ -820,7 +820,7 @@ async def ics_feed(
|
||||
return Response(content=ics_content, media_type="text/calendar")
|
||||
|
||||
|
||||
@router.post("/calendar/import", dependencies=[Depends(require_permission("calendar:write"))])
|
||||
@router.post("/calendar/import", response_model=None, dependencies=[Depends(require_permission("calendar:write"))])
|
||||
async def import_ics(
|
||||
file: UploadFile = File(...),
|
||||
calendar_id: str | None = None,
|
||||
|
||||
@@ -417,7 +417,7 @@ async def delete_folder(
|
||||
# ─── Files ───
|
||||
|
||||
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
|
||||
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=None, dependencies=[Depends(require_permission("dms:write"))])
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
folder_id: str | None = Form(None),
|
||||
@@ -441,35 +441,35 @@ async def upload_file(
|
||||
if folder_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
|
||||
|
||||
# Stream file in chunks — avoid loading entire file into RAM
|
||||
# Stream file to storage — avoid loading entire file into RAM
|
||||
import hashlib
|
||||
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
|
||||
sha256 = hashlib.sha256()
|
||||
file_size = 0
|
||||
chunks: list[bytes] = []
|
||||
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
file_size += len(chunk)
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
|
||||
)
|
||||
sha256.update(chunk)
|
||||
chunks.append(chunk)
|
||||
|
||||
content_hash = sha256.hexdigest()
|
||||
async def chunk_stream():
|
||||
nonlocal file_size
|
||||
while True:
|
||||
chunk = await file.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
file_size += len(chunk)
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
raise HTTPException(
|
||||
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
|
||||
)
|
||||
sha256.update(chunk)
|
||||
yield chunk
|
||||
|
||||
# Create file record
|
||||
file_id = uuid.uuid4()
|
||||
storage_path = _file_storage_path(tenant_id, file_id)
|
||||
|
||||
# Save file via storage backend
|
||||
# Save file via storage backend (true streaming, no RAM accumulation)
|
||||
storage = get_storage_backend()
|
||||
await storage.save(storage_path, b"".join(chunks))
|
||||
del chunks # Free memory
|
||||
await storage.save_stream(storage_path, chunk_stream())
|
||||
|
||||
content_hash = sha256.hexdigest()
|
||||
|
||||
mime_type = file.content_type or "application/octet-stream"
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ async def delete_msg(
|
||||
|
||||
# ─── Attachments ───
|
||||
|
||||
@router.post("/messages/{message_id}/attachments", dependencies=[Depends(require_permission("comm:write"))])
|
||||
@router.post("/messages/{message_id}/attachments", response_model=None, dependencies=[Depends(require_permission("comm:write"))])
|
||||
async def upload_attachment(
|
||||
message_id: str,
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@@ -722,7 +722,7 @@ async def sync_folder(
|
||||
# ─── Attachment Upload (F-MAIL-04) ───
|
||||
|
||||
|
||||
@router.post("/upload-attachment")
|
||||
@router.post("/upload-attachment", response_model=None)
|
||||
async def upload_attachment(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Test sample plugin for LeoCRM plugin system testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
|
||||
class TestSamplePlugin(BasePlugin):
|
||||
"""A sample plugin for testing the plugin lifecycle."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="test_sample",
|
||||
version="1.0.0",
|
||||
display_name="Test Sample Plugin",
|
||||
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
|
||||
dependencies=[],
|
||||
routes=[],
|
||||
events=["contact.created"],
|
||||
migrations=["0001_test_plugin.sql"],
|
||||
permissions=[],
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.install_called = False
|
||||
self.activate_called = False
|
||||
self.deactivate_called = False
|
||||
self.uninstall_called = False
|
||||
self.event_log: list[dict[str, Any]] = []
|
||||
|
||||
async def on_install(self, db, service_container) -> None:
|
||||
self.install_called = True
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
self.activate_called = True
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
self.deactivate_called = True
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
async def on_uninstall(self, db, service_container) -> None:
|
||||
self.uninstall_called = True
|
||||
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
self.event_log.append({"event": "contact.created", "payload": payload})
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Test sample plugin migration: creates a test table with tenant_id
|
||||
CREATE TABLE IF NOT EXISTS test_sample_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_test_sample_items_tenant ON test_sample_items(tenant_id);
|
||||
@@ -39,7 +39,7 @@ class WebhookResponse(BaseModel):
|
||||
tenant_id: uuid.UUID
|
||||
url: str
|
||||
events: list[str]
|
||||
secret: str | None = None
|
||||
has_secret: bool = False # Only indicate if a secret is set, never return it
|
||||
is_active: bool = True
|
||||
retry_count: int = 3
|
||||
timeout_seconds: int = 30
|
||||
|
||||
@@ -237,8 +237,7 @@ class AuthService:
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"ARQ enqueue failed for password reset email — "
|
||||
"raw_token for development: %s",
|
||||
raw_token,
|
||||
"email will not be sent. Check Redis/ARQ connectivity.",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select, delete
|
||||
@@ -18,6 +21,45 @@ from app.models.webhook import Webhook
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_webhook_url(url: str) -> None:
|
||||
"""Validate a webhook URL to prevent SSRF attacks.
|
||||
|
||||
Blocks:
|
||||
- Non-http(s) schemes
|
||||
- Private/internal IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1)
|
||||
- Hostnames that resolve to private IPs (DNS rebinding)
|
||||
- Redirects (handled by httpx follow_redirects=False)
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
|
||||
# Protocol allowlist
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Webhook URL must use http or https, got: {parsed.scheme}")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("Webhook URL has no hostname")
|
||||
|
||||
# Check if hostname is an IP address
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
raise ValueError(f"Webhook URL points to private/reserved IP: {ip}")
|
||||
except ValueError as exc:
|
||||
if "points to private" in str(exc) or "must use" in str(exc):
|
||||
raise
|
||||
# Not an IP — resolve hostname and check
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, None)
|
||||
for family, _, _, _, sockaddr in resolved:
|
||||
addr = sockaddr[0]
|
||||
ip_obj = ipaddress.ip_address(addr)
|
||||
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved:
|
||||
raise ValueError(f"Webhook hostname '{hostname}' resolves to private IP: {addr}")
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Cannot resolve webhook hostname: {hostname}")
|
||||
|
||||
|
||||
async def list_webhooks(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
@@ -151,8 +193,18 @@ async def send_webhook(
|
||||
signature = _sign_payload(body_bytes, webhook.secret)
|
||||
headers["X-Webhook-Signature"] = f"sha256={signature}"
|
||||
|
||||
# SSRF protection: validate URL before sending
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
|
||||
_validate_webhook_url(webhook.url)
|
||||
except ValueError as exc:
|
||||
logger.warning("Webhook URL validation failed for %s: %s", webhook.url, exc)
|
||||
return {"success": False, "status_code": None, "error": f"URL validation failed: {exc}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=webhook.timeout_seconds,
|
||||
follow_redirects=False, # Prevent SSRF via redirect
|
||||
) as client:
|
||||
response = await client.post(
|
||||
webhook.url,
|
||||
content=body_bytes,
|
||||
|
||||
Reference in New Issue
Block a user