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:
@@ -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