实现了完整的Nextcloud Talk聊天适配器,包含以下核心功能: 1. 基础客户端通信与认证 2. 会话路由与聊天类型解析 3. 消息分块与格式转换 4. 用户配对与权限校验 5. 轮询式消息监听 6. 配置验证与诊断工具 7. 安全策略与去重缓存 8. 指标统计与状态快照 新增配套的配对用户缓存文件与模块导出入口。
210 lines
6.5 KiB
Python
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
|