Files

275 lines
9.5 KiB
Python

"""
Webhook Validation Handler (R32).
Dry-run validation for webhook requests without queue submission.
Validates:
- S2: Auth (bearer/hmac, optional replay protection)
- R8: Normalization (common wrappers + camelCase aliases)
- Schema: WebhookJobRequest (strict)
- F5/R8: Template allowlist + rendering
- R33: Render size budget (via services.execution_budgets.check_render_size)
- Warnings: unresolved placeholders in rendered workflow
Routes are registered in `api/routes.py` for both:
- /openclaw/webhook/validate and /api/openclaw/webhook/validate
- legacy /moltbot/webhook/validate and /api/moltbot/webhook/validate
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List
try:
from aiohttp import web # type: ignore
except ModuleNotFoundError: # pragma: no cover
web = None # type: ignore
# Import discipline:
# - ComfyUI runtime: package-relative imports only (prevents collisions with other custom nodes).
# - Unit tests: allow top-level fallbacks.
#
# IMPORTANT (recurring production bug):
# Do NOT wrap these imports in a broad `try/except ImportError`. In ComfyUI, that can silently
# import another pack's top-level `services` module and break allowlists/auth in surprising ways.
if __package__ and "." in __package__:
from ..models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from ..services.execution_budgets import BudgetExceededError, check_render_size
from ..services.metrics import metrics
from ..services.rate_limit import build_rate_limit_response, check_rate_limit
from ..services.templates import get_template_service
from ..services.trace import get_effective_trace_id
from ..services.webhook_auth import require_auth
from ..services.webhook_mapping import apply_mapping, resolve_profile # F40
else: # pragma: no cover (test-only import mode)
from models.schemas import MAX_BODY_SIZE, WebhookJobRequest
from services.execution_budgets import ( # type: ignore
BudgetExceededError,
check_render_size,
)
from services.metrics import metrics # type: ignore
from services.rate_limit import ( # type: ignore
build_rate_limit_response,
check_rate_limit,
)
from services.templates import get_template_service # type: ignore
from services.trace import get_effective_trace_id # type: ignore
from services.webhook_auth import require_auth # type: ignore
from services.webhook_mapping import ( # F40 # type: ignore
apply_mapping,
resolve_profile,
)
# R98: Endpoint Metadata
if __package__ and "." in __package__:
from ..services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
else:
from services.endpoint_manifest import (
AuthTier,
RiskTier,
RoutePlane,
endpoint_metadata,
)
logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_validate")
PLACEHOLDER_PATTERN = re.compile(r"\{\{[^{}]+\}\}")
def _safe_error_response(status: int, error: str, detail: str = "") -> web.Response:
body: Dict[str, Any] = {"ok": False, "error": error}
if detail:
body["detail"] = detail
return web.json_response(body, status=status)
@endpoint_metadata(
auth=AuthTier.WEBHOOK,
risk=RiskTier.LOW,
summary="Webhook validate",
description="Dry-run validation for webhook requests.",
audit="webhook.validate",
plane=RoutePlane.EXTERNAL,
)
async def webhook_validate_handler(request: web.Request) -> web.Response:
"""
POST /openclaw/webhook/validate (legacy: /moltbot/webhook/validate)
"""
if web is None:
raise RuntimeError("aiohttp not available")
# S17: Rate limit (same bucket as submit)
if not check_rate_limit(request, "webhook"):
metrics.inc("webhook_denied")
return build_rate_limit_response(
request,
"webhook",
web_module=web,
error="rate_limit_exceeded",
include_ok=True,
)
# S2: Content-Type + body size
content_type = request.headers.get("Content-Type", "")
if not content_type.startswith("application/json"):
metrics.inc("webhook_denied")
return _safe_error_response(415, "unsupported_media_type")
try:
raw_body = await request.content.read(MAX_BODY_SIZE + 1)
if len(raw_body) > MAX_BODY_SIZE:
metrics.inc("webhook_denied")
return _safe_error_response(
413, "payload_too_large", f"Max body size: {MAX_BODY_SIZE} bytes"
)
except Exception:
metrics.inc("errors")
return _safe_error_response(400, "read_error")
valid, error = require_auth(request, raw_body)
if not valid:
metrics.inc("webhook_denied")
if error in (
"auth_not_configured",
"bearer_not_configured",
"hmac_not_configured",
):
return _safe_error_response(403, error)
return _safe_error_response(401, error)
# R8: Parse JSON + unwrap common envelopes
try:
data: Dict[str, Any] = json.loads(raw_body.decode("utf-8"))
except Exception as e:
metrics.inc("webhook_denied")
return _safe_error_response(400, "invalid_json", str(e))
if "payload" in data and isinstance(data["payload"], dict):
data = data["payload"]
elif "data" in data and isinstance(data["data"], dict):
data = data["data"]
# R8: Common alias normalization (camelCase -> snake_case)
if "templateId" in data:
data["template_id"] = data.pop("templateId")
if "profileId" in data:
data["profile_id"] = data.pop("profileId")
if "jobId" in data:
data["job_id"] = data.pop("jobId")
if "traceId" in data:
data["trace_id"] = data.pop("traceId")
# R25: Trace context (derive after normalization)
trace_id = get_effective_trace_id(request.headers, data)
data["trace_id"] = trace_id
# F40: Payload Mapping Engine
# 1. Resolve profile
mapping_profile = resolve_profile(request.headers)
mapping_warnings = []
# 2. Apply mapping if profile found
if mapping_profile:
try:
data, mapping_warnings = apply_mapping(mapping_profile, data)
except ValueError as e:
metrics.inc("webhook_denied")
return _safe_error_response(400, "mapping_error", str(e))
# Schema validation
try:
job_request = WebhookJobRequest.from_dict(data)
normalized = job_request.to_normalized()
except ValueError as e:
metrics.inc("webhook_denied")
# Log validation error for debugging test failures
logger.warning(f"Webhook validation failed: {e}")
return _safe_error_response(400, "validation_error", str(e))
template_id = normalized["template_id"]
inputs = normalized.get("inputs", {}) or {}
# Render template (dry-run)
template_service = get_template_service()
try:
workflow = template_service.render_template(template_id, inputs)
except ValueError as e:
metrics.inc("webhook_denied")
return _safe_error_response(400, "template_error", str(e))
except Exception as e:
metrics.inc("errors")
logger.exception("Unexpected template rendering failure")
return _safe_error_response(500, "template_error", str(e))
if not isinstance(workflow, dict):
metrics.inc("webhook_denied")
return _safe_error_response(
400,
"template_error",
f"Rendered workflow must be an object, got {type(workflow).__name__}",
)
# R33: Render size budget
try:
check_render_size(workflow, trace_id=trace_id)
except BudgetExceededError as e:
metrics.inc("webhook_denied")
return web.json_response(
{"ok": False, "error": "payload_too_large", "detail": str(e)},
status=413,
headers={"Retry-After": str(e.retry_after)},
)
# Warnings: unresolved placeholders + mapping warnings
warnings: List[str] = mapping_warnings
unresolved: List[str] = []
try:
workflow_json = json.dumps(workflow, ensure_ascii=False, separators=(",", ":"))
workflow_bytes = len(workflow_json.encode("utf-8"))
unresolved = sorted(set(PLACEHOLDER_PATTERN.findall(workflow_json)))[:10]
if unresolved:
warnings.append(
f"Unresolved placeholders detected ({len(unresolved)} shown): {', '.join(unresolved)}"
)
except Exception:
workflow_bytes = 0
# Redact normalized response (security: may contain secrets)
try:
# Import discipline: attempt package-relative first
if __package__ and "." in __package__:
from ..services.redaction import redact_json
else:
from services.redaction import redact_json
safe_normalized = redact_json(normalized)
except ImportError:
# Fallback: use original if redaction not available
safe_normalized = normalized
metrics.inc("webhook_requests_validated")
return web.json_response(
{
"ok": True,
"mapped": bool(mapping_profile), # F40: Indicate if mapping occurred
"trace_id": trace_id,
"template_id": template_id,
"normalized": safe_normalized, # Redacted for security
"render": {
"workflow_bytes": workflow_bytes,
"node_count_estimate": len(workflow),
"unresolved_placeholders": unresolved,
},
"warnings": warnings,
}
)