ForcePilot/backend/package/yuxi/channels/adapters/synologychat/security.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

196 lines
7.9 KiB
Python

"""Security policy for Synology Chat access control.
Controls which users can interact with the bot via DM policy, group policy,
and allowlist mechanisms. Includes rate limiting, security warnings
collection, wildcard support, and pairing mode.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any
from yuxi.channels.models import ChannelMessage, ChatType
from yuxi.utils.logging_config import logger
_DEFAULT_RATE_LIMIT_PER_MINUTE = 30
_DEFAULT_RATE_WINDOW_SECONDS = 60
@dataclass
class _RateWindow:
count: int = 0
window_start: float = 0.0
class RateLimiter:
def __init__(
self, max_requests: int = _DEFAULT_RATE_LIMIT_PER_MINUTE, window_seconds: int = _DEFAULT_RATE_WINDOW_SECONDS
):
self._max_requests = max_requests
self._window_seconds = window_seconds
self._buckets: dict[str, _RateWindow] = {}
self._gc_counter = 0
self._GC_INTERVAL = 1000
def allow(self, key: str) -> bool:
now = time.monotonic()
bucket = self._buckets.get(key)
if bucket is None or now - bucket.window_start > self._window_seconds:
self._buckets[key] = _RateWindow(count=1, window_start=now)
self._maybe_gc(now)
return True
if bucket.count < self._max_requests:
bucket.count += 1
return True
return False
def _maybe_gc(self, now: float) -> None:
self._gc_counter += 1
if self._gc_counter >= self._GC_INTERVAL:
self._gc_counter = 0
cutoff = now - self._window_seconds * 3
stale = [k for k, v in self._buckets.items() if v.window_start < cutoff]
for k in stale:
del self._buckets[k]
def remaining(self, key: str) -> int:
now = time.monotonic()
bucket = self._buckets.get(key)
if bucket is None or now - bucket.window_start > self._window_seconds:
return self._max_requests
return max(0, self._max_requests - bucket.count)
class SynologyChatSecurityPolicy:
def __init__(self, config: dict[str, Any], account_id: str = ""):
sec = config.get("security", {})
if not isinstance(sec, dict):
sec = {}
self._dm_policy = sec.get("dm_policy", "open")
self._group_policy = sec.get("group_policy", "allowlist")
self._allow_from: list[str] = sec.get("allow_from", [])
self._group_allow_from: list[str] = sec.get("group_allow_from", [])
self._pending_pairing: set[str] = set()
rate_cfg = config.get("rate_limit", {})
if not isinstance(rate_cfg, dict):
rate_cfg = {}
self._rate_limiter = RateLimiter(
max_requests=rate_cfg.get("max_per_minute", _DEFAULT_RATE_LIMIT_PER_MINUTE),
window_seconds=rate_cfg.get("window_seconds", _DEFAULT_RATE_WINDOW_SECONDS),
)
self._account_id = account_id
@property
def dm_policy(self) -> str:
return self._dm_policy
@property
def group_policy(self) -> str:
return self._group_policy
def check(self, msg: ChannelMessage) -> bool:
user_id = msg.identity.channel_user_id
rate_key = f"{self._account_id}:{user_id}" if self._account_id else user_id
if not self._rate_limiter.allow(rate_key):
logger.warning(f"[SynologyChat] Rate limit exceeded for user {user_id}")
return False
if msg.chat_type == ChatType.DIRECT:
return self._check_dm(msg)
return self._check_group(msg)
def check_rate(self, user_id: str) -> bool:
rate_key = f"{self._account_id}:{user_id}" if self._account_id else user_id
return self._rate_limiter.allow(rate_key)
def _check_dm(self, msg: ChannelMessage) -> bool:
user_id = msg.identity.channel_user_id
if self._dm_policy == "open":
return True
if self._dm_policy == "disabled":
logger.debug(f"[SynologyChat] DM blocked (policy=disabled) for user {user_id}")
return False
if self._dm_policy == "allowlist":
allowed = self._is_allowed(user_id, self._allow_from)
if not allowed:
logger.debug(f"[SynologyChat] DM blocked (allowlist) for user {user_id}")
return allowed
if self._dm_policy == "pairing":
if self._is_allowed(user_id, self._allow_from):
return True
self._pending_pairing.add(user_id)
logger.info(f"[SynologyChat] DM pairing required for user {user_id}")
return False
logger.warning(f"[SynologyChat] Unknown dm_policy '{self._dm_policy}', falling back to open")
return True
def _check_group(self, msg: ChannelMessage) -> bool:
user_id = msg.identity.channel_user_id
chat_id = msg.identity.channel_chat_id
if self._group_policy == "open":
return True
if self._group_policy == "disabled":
logger.debug(f"[SynologyChat] Group message blocked (policy=disabled) from user {user_id}")
return False
if self._group_policy == "allowlist":
if self._is_allowed(user_id, self._group_allow_from):
return True
logger.debug(f"[SynologyChat] Group message blocked (allowlist) from user {user_id} in chat {chat_id}")
return False
logger.warning(f"[SynologyChat] Unknown group_policy '{self._group_policy}', falling back to open")
return True
def _is_allowed(self, user_id: str, allowlist: list[str]) -> bool:
if "*" in allowlist:
return True
return user_id in allowlist
def is_pairing_required(self, user_id: str) -> bool:
return self._dm_policy == "pairing" and user_id in self._pending_pairing
def approve_pairing(self, user_id: str) -> None:
self._pending_pairing.discard(user_id)
if user_id not in self._allow_from:
self._allow_from.append(user_id)
def deny_pairing(self, user_id: str) -> None:
self._pending_pairing.discard(user_id)
def add_allowed_user(self, user_id: str, scope: str = "dm") -> bool:
if scope == "dm":
target = self._allow_from
elif scope == "group":
target = self._group_allow_from
else:
return False
if user_id not in target:
target.append(user_id)
logger.info(f"[SynologyChat] Added user {user_id} to {scope} allow_from")
return True
return False
def collect_security_warnings(self) -> list[str]:
warnings: list[str] = []
if self._dm_policy == "open":
warnings.append("dm_policy is 'open': any user can DM the bot without restriction")
if self._group_policy == "open":
warnings.append("group_policy is 'open': any group member can trigger the bot")
if not self._allow_from and self._dm_policy == "allowlist":
warnings.append("dm_policy is 'allowlist' but allow_from is empty — no users permitted")
if not self._group_allow_from and self._group_policy == "allowlist":
warnings.append("group_policy is 'allowlist' but group_allow_from is empty — no users permitted")
if self._dm_policy == "pairing" and not self._allow_from:
warnings.append("pairing mode active but initial allow_from is empty — all users require approval")
return warnings
def rebuild_rate_limiter(self, max_per_minute: int | None = None, window_seconds: int | None = None) -> None:
if max_per_minute is not None or window_seconds is not None:
new_max = max_per_minute if max_per_minute is not None else self._rate_limiter._max_requests
new_window = window_seconds if window_seconds is not None else self._rate_limiter._window_seconds
self._rate_limiter = RateLimiter(max_requests=new_max, window_seconds=new_window)
logger.info(f"[SynologyChat] Rate limiter rebuilt: max_per_minute={new_max}, window_seconds={new_window}")