这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
221 lines
7.0 KiB
Python
221 lines
7.0 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
|
|
|
|
stripped = content.strip()
|
|
if stripped.startswith("/"):
|
|
cmd_lower = stripped.lower()
|
|
authorized_commands = config.get(
|
|
"mention_gate_authorized_commands", ["/pair", "/unpair", "/approve", "/status"]
|
|
)
|
|
for cmd in authorized_commands:
|
|
if cmd_lower.startswith(cmd.lower() + " ") or cmd_lower == cmd.lower():
|
|
logger.debug(f"[NextcloudTalk] Mention gate bypassed for authorized command in {token}")
|
|
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
|