ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/security.py
Kris b018ad25da feat(backend): add Nextcloud Talk channel adapter implementation
实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能:
1. 基础客户端通信与认证
2. 会话路由与聊天类型解析
3. 消息分块与格式转换
4. 用户配对与权限校验
5. 轮询式消息监听
6. 配置验证与诊断工具
7. 安全策略与去重缓存
8. 指标统计与状态快照

新增配套的配对用户缓存文件与模块导出入口。
2026-05-12 00:47:06 +08:00

210 lines
6.5 KiB
Python

from __future__ import annotations
import json
import logging
import os
import re
from typing import Any
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_PERSIST_PATH = os.getenv(
"NEXTCLOUDTALK_PAIRED_USERS_FILE",
os.path.join(os.path.dirname(__file__), ".paired_users.json"),
)
_PAIRED_USERS: set[str] | None = None
def _get_paired_users() -> set[str]:
global _PAIRED_USERS
if _PAIRED_USERS is None:
_PAIRED_USERS = set()
_load_paired_users()
return _PAIRED_USERS
def _load_paired_users() -> None:
try:
if os.path.exists(_PERSIST_PATH):
with open(_PERSIST_PATH, encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
_get_paired_users().update(data)
except Exception:
pass
def _save_paired_users() -> None:
try:
with open(_PERSIST_PATH, "w", encoding="utf-8") as f:
json.dump(sorted(_get_paired_users()), f)
except Exception as e:
logger.warning(f"[NextcloudTalk] Failed to persist paired users: {e}")
def check_dm_policy(user_id: str, config: dict[str, Any]) -> bool:
dm_policy = config.get("dm_policy", "pairing")
match dm_policy:
case "open":
return True
case "disabled":
return False
case "pairing":
return _is_user_paired(user_id)
case "allowlist":
allow_from = config.get("allow_from", config.get("allowFrom", []))
return f"nc:{user_id}" in allow_from
case _:
return False
def check_group_policy(token: str, user_id: str, config: dict[str, Any]) -> bool:
group_policy = config.get("group_policy", "allowlist")
match group_policy:
case "open":
return True
case "disabled":
return False
case "allowlist":
groups = config.get("groups", {})
conv_config = groups.get(token, {})
per_group_allow = conv_config.get("allow_from", conv_config.get("allowFrom", []))
if f"nc:{user_id}" in per_group_allow:
return True
global_allow = config.get("group_allow_from", config.get("groupAllowFrom", []))
return f"nc:{user_id}" in global_allow
case _:
return False
def _is_user_paired(user_id: str) -> bool:
return user_id in _get_paired_users()
def pair_user(user_id: str) -> None:
_get_paired_users().add(user_id)
_save_paired_users()
def unpair_user(user_id: str) -> None:
_get_paired_users().discard(user_id)
_save_paired_users()
def check_mention_gate(
token: str,
content: str,
bot_name: str,
config: dict[str, Any],
raw_message_params: dict[str, Any] | None = None,
) -> bool:
require_mention = True
rooms_cfg = config.get("rooms", {})
room_cfg = rooms_cfg.get(token, rooms_cfg.get("*", {}))
if "requireMention" in room_cfg:
require_mention = room_cfg["requireMention"]
elif "require_mention" in room_cfg:
require_mention = room_cfg["require_mention"]
if not require_mention:
return True
mention_check_content = content.lower()
mention_check_bot_name = bot_name.lower()
if raw_message_params:
for key, param in raw_message_params.items():
if key.startswith("mention"):
ptype = param.get("type", "")
param_name = param.get("name", "")
if ptype in ("user", "guest"):
if param_name and param_name.lower() == mention_check_bot_name:
return True
if f"@{mention_check_bot_name}" in mention_check_content:
return True
logger.debug(f"[NextcloudTalk] Mention gate blocked: bot '{bot_name}' not mentioned in group {token}")
return False
def validate_nextcloud_url(url: str, server_url: str, audit_context: str = "") -> bool:
_ctx = f"[{audit_context}] " if audit_context else ""
if re.search(r"/\.{1,2}/", url):
logger.warning(f"[NextcloudTalk] {_ctx}SSRF blocked: path traversal detected in {url}")
return False
parsed = urlparse(url)
server_parsed = urlparse(server_url)
if parsed.scheme not in ("https", "http"):
logger.warning(f"[NextcloudTalk] {_ctx}SSRF blocked: invalid scheme in {url}")
return False
if parsed.hostname != server_parsed.hostname:
logger.warning(
f"[NextcloudTalk] {_ctx}SSRF blocked: external host {parsed.hostname} != {server_parsed.hostname}"
)
return False
if parsed.port and parsed.port != server_parsed.port and parsed.port not in (80, 443):
logger.warning(f"[NextcloudTalk] {_ctx}SSRF blocked: port mismatch {parsed.port}")
return False
return True
def validate_backend_source(
headers: dict[str, str],
server_url: str,
audit_context: str = "",
) -> bool:
_ctx = f"[{audit_context}] " if audit_context else ""
backend_header = headers.get("X-Nextcloud-Talk-Backend", headers.get("x-nextcloud-talk-backend", ""))
if not backend_header:
return False
parsed_backend = urlparse(backend_header)
parsed_server = urlparse(server_url)
if parsed_backend.hostname != parsed_server.hostname:
logger.warning(
f"[NextcloudTalk] {_ctx}Backend source mismatch: {parsed_backend.hostname} != {parsed_server.hostname}"
)
return False
return True
def collect_security_warnings(config: dict[str, Any]) -> list[str]:
warnings: list[str] = []
group_policy = config.get("group_policy", "allowlist")
if group_policy == "open":
warnings.append("group_policy is set to 'open' - bot responds to all group messages")
dm_policy = config.get("dm_policy", "pairing")
if dm_policy == "open":
warnings.append("dm_policy is set to 'open' - bot responds to all DM messages without pairing")
groups = config.get("groups", {})
if not groups and group_policy == "allowlist":
warnings.append(
"No per-room allowlist configured - group messages will be blocked unless global allowlist is set"
)
allow_from = config.get("allow_from", config.get("allowFrom", []))
if dm_policy == "allowlist" and not allow_from:
warnings.append("DM allowlist policy active but allow_from is empty - all DMs will be blocked")
rooms_cfg = config.get("rooms", {})
if not rooms_cfg:
warnings.append("No per-room configuration - mention gate defaults to requireMention=true for all group chats")
return warnings