import hashlib import hmac import logging import time from dataclasses import dataclass from enum import StrEnum logger = logging.getLogger(__name__) PRE_AUTH_BODY_SIZE_LIMIT = 64 * 1024 POST_AUTH_BODY_SIZE_LIMIT = 1 * 1024 * 1024 PRE_AUTH_READ_TIMEOUT = 5.0 POST_AUTH_READ_TIMEOUT = 30.0 MAX_CONCURRENT_PER_KEY = 8 MAX_TRACKING_KEYS = 4096 _VALID_WEBHOOK_METHODS = frozenset({"POST", "PUT", "PATCH"}) class WebhookErrorCode(StrEnum): METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED" CONTENT_TYPE_UNSUPPORTED = "CONTENT_TYPE_UNSUPPORTED" BODY_TOO_LARGE = "BODY_TOO_LARGE" TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS" SIGNATURE_MISSING = "SIGNATURE_MISSING" SIGNATURE_INVALID = "SIGNATURE_INVALID" PAYLOAD_INVALID = "PAYLOAD_INVALID" @dataclass class WebhookGuardConfig: channel_type: str allowed_methods: tuple[str, ...] = ("POST",) allowed_content_types: tuple[str, ...] = ("application/json",) pre_auth: bool = True verify_signature: bool = True body_size_limit: int = PRE_AUTH_BODY_SIZE_LIMIT import threading class WebhookAnomalyTracker: def __init__(self, sample_window: float = 60.0, alert_threshold: int = 50): self._sample_window = sample_window self._alert_threshold = alert_threshold self._counters: dict[str, dict[str, int]] = {} self._last_reset: float = time.monotonic() self._lock = threading.Lock() def record(self, channel_type: str, error_code: str) -> None: now = time.monotonic() with self._lock: if now - self._last_reset > self._sample_window: self._counters.clear() self._last_reset = now channel_counters = self._counters.setdefault(channel_type, {}) channel_counters[error_code] = channel_counters.get(error_code, 0) + 1 total = sum(channel_counters.values()) if total >= self._alert_threshold and total % self._alert_threshold == 0: self._emit_alert(channel_type, channel_counters) def _emit_alert(self, channel_type: str, counters: dict[str, int]) -> None: details = ", ".join(f"{k}={v}" for k, v in counters.items()) logger.warning( "Webhook anomaly alert [%s]: %d anomalies in window — %s", channel_type, sum(counters.values()), details, ) def get_stats(self, channel_type: str | None = None) -> dict[str, dict[str, int]]: with self._lock: if channel_type: return {channel_type: self._counters.get(channel_type, {})} return dict(self._counters) class WebhookSigner: def __init__(self): self._signing_keys: dict[str, str] = {} def set_channel_key(self, channel_type: str, signing_key: str) -> None: self._signing_keys[channel_type] = signing_key def remove_channel_key(self, channel_type: str) -> None: self._signing_keys.pop(channel_type, None) def compute_signature(self, channel_type: str, body: bytes) -> str | None: key = self._signing_keys.get(channel_type) if not key: return None return hmac.new(key.encode(), body, hashlib.sha256).hexdigest() def verify( self, channel_type: str, body: bytes, signature: str, ) -> bool: expected = self.compute_signature(channel_type, body) if expected is None: return False return hmac.compare_digest(expected, signature) @staticmethod def compute_sha256_signature(secret: str, body: bytes) -> str: return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() class WebhookConcurrencyGuard: def __init__(self, max_per_key: int = MAX_CONCURRENT_PER_KEY, max_keys: int = MAX_TRACKING_KEYS): self._max_per_key = max_per_key self._max_keys = max_keys self._inflight: dict[str, int] = {} self._lock = threading.Lock() def try_acquire(self, channel_type: str) -> bool: with self._lock: current = self._inflight.get(channel_type, 0) if current >= self._max_per_key: return False if len(self._inflight) >= self._max_keys and channel_type not in self._inflight: return False self._inflight[channel_type] = current + 1 return True def release(self, channel_type: str) -> None: with self._lock: current = self._inflight.get(channel_type, 0) if current <= 1: self._inflight.pop(channel_type, None) else: self._inflight[channel_type] = current - 1 @property def inflight_count(self) -> dict[str, int]: with self._lock: return dict(self._inflight) @dataclass class WebhookGuardResult: allowed: bool error_code: WebhookErrorCode | None = None error_message: str | None = None http_status: int = 200 class WebhookGuard: def __init__( self, signer: WebhookSigner | None = None, concurrency_guard: WebhookConcurrencyGuard | None = None, anomaly_tracker: WebhookAnomalyTracker | None = None, ): self._signer = signer or WebhookSigner() self._concurrency = concurrency_guard or WebhookConcurrencyGuard() self._anomaly = anomaly_tracker or WebhookAnomalyTracker() self._channel_configs: dict[str, WebhookGuardConfig] = {} def register_channel(self, channel_type: str, config: WebhookGuardConfig | None = None) -> None: self._channel_configs[channel_type] = config or WebhookGuardConfig(channel_type=channel_type) def unregister_channel(self, channel_type: str) -> None: self._channel_configs.pop(channel_type, None) def check_method(self, channel_type: str, method: str) -> WebhookGuardResult: cfg = self._channel_configs.get(channel_type) allowed = list(cfg.allowed_methods) if cfg else ["POST"] if method not in _VALID_WEBHOOK_METHODS or method not in allowed: self._anomaly.record(channel_type, "405") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.METHOD_NOT_ALLOWED, error_message=f"Method {method} not allowed", http_status=405, ) return WebhookGuardResult(allowed=True) def check_content_type(self, channel_type: str, content_type: str | None) -> WebhookGuardResult: cfg = self._channel_configs.get(channel_type) allowed = list(cfg.allowed_content_types) if cfg else ["application/json"] if not content_type: self._anomaly.record(channel_type, "415") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.CONTENT_TYPE_UNSUPPORTED, error_message="Content-Type header missing", http_status=415, ) ctype_main = content_type.split(";")[0].strip().lower() if ctype_main not in allowed: self._anomaly.record(channel_type, "415") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.CONTENT_TYPE_UNSUPPORTED, error_message=f"Content-Type {content_type} not supported", http_status=415, ) return WebhookGuardResult(allowed=True) def check_body_size(self, channel_type: str, body: bytes) -> WebhookGuardResult: cfg = self._channel_configs.get(channel_type) limit = cfg.body_size_limit if cfg else PRE_AUTH_BODY_SIZE_LIMIT if len(body) > limit: self._anomaly.record(channel_type, "413") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.BODY_TOO_LARGE, error_message=f"Body size {len(body)} exceeds limit {limit}", http_status=413, ) return WebhookGuardResult(allowed=True) def check_concurrency(self, channel_type: str) -> WebhookGuardResult: if not self._concurrency.try_acquire(channel_type): self._anomaly.record(channel_type, "429") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.TOO_MANY_REQUESTS, error_message="Too many concurrent requests", http_status=429, ) return WebhookGuardResult(allowed=True) def release_concurrency(self, channel_type: str) -> None: self._concurrency.release(channel_type) def check_signature( self, channel_type: str, body: bytes, signature: str | None, ) -> WebhookGuardResult: cfg = self._channel_configs.get(channel_type) if cfg is None or not cfg.verify_signature: return WebhookGuardResult(allowed=True) if not signature: self._anomaly.record(channel_type, "401") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.SIGNATURE_MISSING, error_message="Webhook signature missing", http_status=401, ) if not self._signer.verify(channel_type, body, signature): self._anomaly.record(channel_type, "401") return WebhookGuardResult( allowed=False, error_code=WebhookErrorCode.SIGNATURE_INVALID, error_message="Webhook signature invalid", http_status=401, ) return WebhookGuardResult(allowed=True) def run_pipeline( self, channel_type: str, method: str, content_type: str | None, body: bytes, signature: str | None = None, ) -> WebhookGuardResult: result = self.check_method(channel_type, method) if not result.allowed: return result result = self.check_content_type(channel_type, content_type) if not result.allowed: return result result = self.check_body_size(channel_type, body) if not result.allowed: return result result = self.check_signature(channel_type, body, signature) if not result.allowed: return result result = self.check_concurrency(channel_type) if not result.allowed: return result return WebhookGuardResult(allowed=True) @property def signer(self) -> WebhookSigner: return self._signer @property def anomaly_tracker(self) -> WebhookAnomalyTracker: return self._anomaly @property def concurrency_guard(self) -> WebhookConcurrencyGuard: return self._concurrency webhook_guard = WebhookGuard()