Files
leocrm/app/services/webhook_service.py
T
Agent Zero 5ec1fc9b05 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.
2026-07-26 20:45:42 +02:00

236 lines
6.9 KiB
Python

"""CRUD service for Webhook subscriptions and delivery."""
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
from sqlalchemy.ext.asyncio import AsyncSession
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,
event: str | None = None,
) -> list[Webhook]:
"""List webhooks for a tenant, optionally filtered by event."""
stmt = select(Webhook).where(
Webhook.tenant_id == tenant_id,
)
if event:
stmt = stmt.where(Webhook.events.any(event))
stmt = stmt.order_by(Webhook.created_at.desc())
result = await db.execute(stmt)
return list(result.scalars().all())
async def get_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
) -> Webhook | None:
"""Get a single webhook by ID."""
stmt = select(Webhook).where(
Webhook.id == webhook_id,
Webhook.tenant_id == tenant_id,
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def create_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> Webhook:
"""Create a new webhook subscription."""
webhook = Webhook(
tenant_id=tenant_id,
url=data["url"],
events=data["events"],
secret=data.get("secret"),
is_active=data.get("is_active", True),
retry_count=data.get("retry_count", 3),
timeout_seconds=data.get("timeout_seconds", 30),
created_by=user_id,
updated_by=user_id,
)
db.add(webhook)
await db.flush()
await db.refresh(webhook)
return webhook
async def update_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
data: dict[str, Any],
user_id: uuid.UUID | None = None,
) -> Webhook | None:
"""Update an existing webhook subscription."""
webhook = await get_webhook(db, tenant_id, webhook_id)
if webhook is None:
return None
update_fields = ["url", "events", "secret", "is_active", "retry_count", "timeout_seconds"]
for field in update_fields:
if field in data:
setattr(webhook, field, data[field])
if user_id is not None:
webhook.updated_by = user_id
await db.flush()
await db.refresh(webhook)
return webhook
async def delete_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
) -> bool:
"""Delete a webhook subscription."""
stmt = select(Webhook).where(
Webhook.id == webhook_id,
Webhook.tenant_id == tenant_id,
)
result = await db.execute(stmt)
webhook = result.scalar_one_or_none()
if webhook is None:
return False
await db.delete(webhook)
await db.flush()
return True
def _sign_payload(payload: bytes, secret: str) -> str:
"""Create HMAC-SHA256 signature for a payload."""
return hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
async def send_webhook(
webhook: Webhook,
event_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Send a webhook HTTP POST with HMAC signature.
Returns a dict with 'success' (bool), 'status_code' (int | None),
and 'error' (str | None).
"""
body = {
"event": event_name,
"payload": payload,
"timestamp": __import__("datetime").datetime.utcnow().isoformat() + "Z",
}
body_bytes = json.dumps(body, default=str).encode("utf-8")
headers = {
"Content-Type": "application/json",
"User-Agent": "LeoCRM-Webhook/1.0",
}
if webhook.secret:
signature = _sign_payload(body_bytes, webhook.secret)
headers["X-Webhook-Signature"] = f"sha256={signature}"
# SSRF protection: validate URL before sending
try:
_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,
headers=headers,
)
return {
"success": response.is_success,
"status_code": response.status_code,
"error": None,
}
except httpx.TimeoutException:
return {"success": False, "status_code": None, "error": "timeout"}
except httpx.RequestError as exc:
return {"success": False, "status_code": None, "error": str(exc)}
async def retry_webhook(
db: AsyncSession,
tenant_id: uuid.UUID,
webhook_id: uuid.UUID,
event_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
"""Retry sending a webhook with the given event and payload."""
webhook = await get_webhook(db, tenant_id, webhook_id)
if webhook is None:
return {"success": False, "status_code": None, "error": "webhook_not_found"}
return await send_webhook(webhook, event_name, payload)