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