d22175310a
🚨 Severity: HIGH 💡 Vulnerability: User-provided IMAP `host` in `_test_imap_connection` and `pull_inbox` was not validated against private IPs, creating an SSRF risk. 🎯 Impact: Attackers could abuse the endpoints to port-scan or interact with internal/private network services. 🔧 Fix: Integrated `is_private_ip` from `app.utils.network` to block connections resolving to private, loopback, link-local, or reserved IPs. ✅ Verification: Ran `test_imap_tasks.py` and `test_api_imap_accounts.py` successfully. Checked `ruff` output and diffs. Removed all scratch files from the commit. Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
172 lines
5.4 KiB
Python
172 lines
5.4 KiB
Python
"""Webhook delivery utility for notifying external systems of document events.
|
||
|
||
Provides functions to dispatch webhook payloads with HMAC-SHA256 signatures
|
||
and to query active webhook configurations from the database.
|
||
|
||
Supported events:
|
||
- ``document.uploaded`` – a new document has been ingested
|
||
- ``document.processed`` – a document finished processing successfully
|
||
- ``document.failed`` – document processing failed
|
||
- ``user.signup`` – a new user account was created
|
||
- ``user.plan_changed`` – a user's subscription plan changed
|
||
- ``user.payment_issue`` – a payment issue was reported for a user
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import time
|
||
from typing import Any
|
||
|
||
import requests
|
||
|
||
from app.database import SessionLocal
|
||
from app.models import WebhookConfig
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: Events recognised by the webhook subsystem.
|
||
VALID_EVENTS: frozenset[str] = frozenset(
|
||
{
|
||
"document.uploaded",
|
||
"document.processed",
|
||
"document.failed",
|
||
"user.signup",
|
||
"user.plan_changed",
|
||
"user.payment_issue",
|
||
}
|
||
)
|
||
|
||
#: Timeout (seconds) for outgoing webhook HTTP requests.
|
||
WEBHOOK_TIMEOUT = 10
|
||
|
||
|
||
def compute_signature(payload_bytes: bytes, secret: str) -> str:
|
||
"""Compute an HMAC-SHA256 hex-digest for *payload_bytes* using *secret*.
|
||
|
||
Args:
|
||
payload_bytes: The raw JSON body to sign.
|
||
secret: The shared secret string.
|
||
|
||
Returns:
|
||
``sha256=<hex-digest>`` signature string.
|
||
"""
|
||
mac = hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256)
|
||
return f"sha256={mac.hexdigest()}"
|
||
|
||
|
||
def deliver_webhook(url: str, payload: dict[str, Any], secret: str | None = None) -> bool:
|
||
"""Send a single webhook POST request.
|
||
|
||
Args:
|
||
url: Target URL.
|
||
payload: JSON-serialisable dictionary.
|
||
secret: If provided, an ``X-Webhook-Signature`` header is included.
|
||
|
||
Returns:
|
||
``True`` when the remote server responds with a 2xx status.
|
||
"""
|
||
body = json.dumps(payload, default=str, sort_keys=True)
|
||
body_bytes = body.encode("utf-8")
|
||
|
||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||
if secret:
|
||
headers["X-Webhook-Signature"] = compute_signature(body_bytes, secret)
|
||
|
||
try:
|
||
resp = requests.post(url, data=body_bytes, headers=headers, timeout=WEBHOOK_TIMEOUT)
|
||
if resp.ok:
|
||
logger.info("Webhook delivered to %s (status %d)", url, resp.status_code)
|
||
return True
|
||
logger.warning("Webhook to %s returned status %d", url, resp.status_code)
|
||
return False
|
||
except requests.RequestException as exc:
|
||
logger.error("Webhook delivery to %s failed: %s", url, exc)
|
||
return False
|
||
|
||
|
||
def build_payload(event: str, data: dict[str, Any]) -> dict[str, Any]:
|
||
"""Build a standardised webhook payload envelope.
|
||
|
||
Args:
|
||
event: The event name (e.g. ``document.uploaded``).
|
||
data: Event-specific data.
|
||
|
||
Returns:
|
||
Dictionary with ``event``, ``timestamp``, and ``data`` keys.
|
||
"""
|
||
return {
|
||
"event": event,
|
||
"timestamp": time.time(),
|
||
"data": data,
|
||
}
|
||
|
||
|
||
def get_active_webhooks_for_event(event: str) -> list[dict[str, Any]]:
|
||
"""Return all active webhook configs subscribed to *event*.
|
||
|
||
Queries the database directly so this helper can be called from both the
|
||
API layer and Celery tasks.
|
||
|
||
Args:
|
||
event: The event name to filter on.
|
||
|
||
Returns:
|
||
A list of dicts with ``id``, ``url``, ``secret``, and ``events`` keys.
|
||
"""
|
||
db = SessionLocal()
|
||
try:
|
||
configs = db.query(WebhookConfig).filter(WebhookConfig.is_active.is_(True)).all()
|
||
result: list[dict[str, Any]] = []
|
||
for cfg in configs:
|
||
try:
|
||
subscribed = json.loads(cfg.events)
|
||
except (json.JSONDecodeError, TypeError):
|
||
subscribed = []
|
||
if event in subscribed:
|
||
result.append(
|
||
{
|
||
"id": cfg.id,
|
||
"url": cfg.url,
|
||
"secret": cfg.secret,
|
||
"events": subscribed,
|
||
}
|
||
)
|
||
return result
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
|
||
"""Fan-out a webhook event to all matching active configurations.
|
||
|
||
This is the main entry-point used by application code to trigger webhooks.
|
||
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
|
||
webhook so delivery happens asynchronously with automatic retries.
|
||
|
||
Args:
|
||
event: Event name (must be in :data:`VALID_EVENTS`).
|
||
data: Event-specific payload data.
|
||
"""
|
||
if event not in VALID_EVENTS:
|
||
logger.warning("Ignoring unknown webhook event: %s", event)
|
||
return
|
||
|
||
webhooks = get_active_webhooks_for_event(event)
|
||
if not webhooks:
|
||
logger.debug("No active webhooks for event %s", event)
|
||
return
|
||
|
||
payload = build_payload(event, data)
|
||
|
||
# Import here to avoid circular dependency with celery_app
|
||
from app.tasks.webhook_tasks import deliver_webhook_task
|
||
|
||
for wh in webhooks:
|
||
try:
|
||
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
|
||
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
|
||
except Exception as exc:
|
||
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
|