ForcePilot/backend/package/yuxi/channel/gateway/webhook_security.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

305 lines
11 KiB
Python

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()