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:
Agent Zero
2026-07-26 20:45:42 +02:00
parent 7a14973c68
commit 5ec1fc9b05
32 changed files with 1781 additions and 76 deletions
+1 -2
View File
@@ -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,
)
+53 -1
View File
@@ -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,