From 724eeb92d9aa6629004eb1184ea4f25a7f782a26 Mon Sep 17 00:00:00 2001 From: anten-ka Date: Fri, 10 Apr 2026 13:30:47 +0300 Subject: [PATCH] fix(v2.4.2): iter2 audit fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bot.py: safe_edit_message now accepts disable_web_page_preview (CRIT: was TypeError in cb_pro_confirm success path) - bot.py: status display uses template_id field (was 'template' — mismatch with save_gotelegram_config, template never showed) - bot.py: cb_pro_confirm validates tpl_id against [A-Za-z0-9_-]{1,64} before subprocess (defense-in-depth) - bot.py: cb_lite_domain validates domain shape - bot.py: asyncio.Lock _BOT_ACTION_LOCK serializes concurrent change-template/change-lite-domain calls - install.sh: bot_update_config_field uses shell `date -Iseconds` instead of jq's `now|todate` (jq 1.5 compat for Debian 10) --- gotelegram-bot/bot.py | 109 ++++++++++++++++++++++++++++++++++++------ install.sh | 11 +++-- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 408b64f..8c4ade7 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -240,6 +240,24 @@ async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]: return (-1, "", str(e)) +# Per-host mutex preventing concurrent install.sh --action invocations. Two +# admins hitting "change template" at the same second could race each other +# and corrupt /var/www/gotelegram-site. One global lock is fine — these are +# rare operations and should serialize cleanly. +_BOT_ACTION_LOCK = asyncio.Lock() + +# Allowed template-id shape: catalog ids are [a-zA-Z0-9_-], never longer than 64. +# This is a defense-in-depth check before we hand the value to subprocess. +_TPL_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + +# Allowed Lite mask domain shape — simple DNS hostname, up to 253 chars total. +# Each label 1–63 chars, labels separated by dots, alphanumerics + hyphens. +_DOMAIN_RE = re.compile( + r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(? Dict: """Invoke install.sh --action=X --json and parse the JSON result. @@ -324,12 +342,23 @@ def save_json(path: str, data: Dict) -> bool: return False -async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None) -> bool: - """Safely edit message, handling cases where message was deleted or not modified.""" +async def safe_edit_message( + query, + text: str, + reply_markup=None, + parse_mode=None, + disable_web_page_preview: Optional[bool] = None, +) -> bool: + """Safely edit message, handling cases where message was deleted or not modified. + + `disable_web_page_preview` is forwarded to edit_message_text when set; omitting + it keeps Telegram's default (enabled). + """ + kwargs = {"reply_markup": reply_markup, "parse_mode": parse_mode} + if disable_web_page_preview is not None: + kwargs["disable_web_page_preview"] = disable_web_page_preview try: - await query.edit_message_text( - text, reply_markup=reply_markup, parse_mode=parse_mode - ) + await query.edit_message_text(text, **kwargs) return True except BadRequest as e: err_msg = str(e).lower() @@ -621,11 +650,13 @@ async def get_status_text(user_id: Optional[int] = None) -> str: config = load_json(GOTELEGRAM_CONFIG) if config: lines.append(f"{_t(user_id, 'status_mode')}: {html.escape(str(config.get('mode', 'unknown')))}") - if "template" in config: - lines.append(f"{_t(user_id, 'status_template')}: {html.escape(str(config['template']))}") - if "domain" in config: + # install.sh/save_gotelegram_config uses "template_id" (not "template") + tpl = config.get("template_id") or config.get("template") + if tpl: + lines.append(f"{_t(user_id, 'status_template')}: {html.escape(str(tpl))}") + if config.get("domain"): lines.append(f"{_t(user_id, 'status_domain')}: {html.escape(str(config['domain']))}") - if "port" in config: + if config.get("port"): lines.append(f"{_t(user_id, 'status_port')}: {html.escape(str(config['port']))}") # Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...) @@ -878,6 +909,13 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.answer("Invalid domain selection") return + # Defense-in-depth: LITE_DOMAINS is trusted, but validate the shape anyway + # in case someone extends the list with garbage later. + if not _DOMAIN_RE.match(domain): + logger.warning(f"cb_lite_domain: rejecting malformed domain {domain!r}") + await query.answer("Invalid domain") + return + await query.answer() config = load_json(GOTELEGRAM_CONFIG) or {} @@ -898,6 +936,18 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return # Lite active — switch fake-TLS mask domain in place + if _BOT_ACTION_LOCK.locked(): + await safe_edit_message( + query, + "⏳ Другая операция уже выполняется\n\n" + "Дождитесь завершения предыдущей смены шаблона/домена и повторите.", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + progress_text = ( "⏳ Меняю маскировочный домен...\n\n" f"Новый домен: {html.escape(domain)}\n\n" @@ -905,7 +955,8 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) await safe_edit_message(query, progress_text, parse_mode="HTML") - result = await run_bot_action("change-lite-domain", timeout=30, domain=domain) + async with _BOT_ACTION_LOCK: + result = await run_bot_action("change-lite-domain", timeout=30, domain=domain) if result.get("status") == "success": text = ( @@ -1219,6 +1270,21 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.answer() + # Defense-in-depth: even though subprocess.exec uses list args (no shell), + # we still enforce the catalog id shape before handing it to install.sh. + if not _TPL_ID_RE.match(tpl_id): + logger.warning(f"cb_pro_confirm: rejecting malformed tpl_id {tpl_id!r}") + await safe_edit_message( + query, + "❌ Некорректный идентификатор шаблона\n\n" + "Выбран неподдерживаемый шаблон. Вернитесь в меню и попробуйте снова.", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + # Read current config to decide: in-place change-template vs fresh install config = load_json(GOTELEGRAM_CONFIG) or {} current_mode = config.get("mode", "") @@ -1240,6 +1306,18 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return # Pro mode is active — perform change-template in place + if _BOT_ACTION_LOCK.locked(): + await safe_edit_message( + query, + "⏳ Другая операция уже выполняется\n\n" + "Дождитесь завершения предыдущей смены шаблона/домена и повторите.", + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]] + ), + parse_mode="HTML", + ) + return + progress_text = ( "⏳ Меняю шаблон сайта...\n\n" f"Шаблон: {html.escape(tpl_id)}\n\n" @@ -1248,8 +1326,10 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) await safe_edit_message(query, progress_text, parse_mode="HTML") - # Template download + git clone can be slow — generous timeout - result = await run_bot_action("change-template", timeout=180, template=tpl_id) + # Template download + git clone can be slow — generous timeout. + # Mutex serializes with any concurrent change-lite-domain/change-template. + async with _BOT_ACTION_LOCK: + result = await run_bot_action("change-template", timeout=180, template=tpl_id) if result.get("status") == "success": domain = result.get("domain", config.get("domain", "")) @@ -2209,9 +2289,10 @@ async def handle_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE if not ok: await update.message.reply_text(_t(user_id, info), parse_mode="HTML") return - # Success — record in GoTelegram config + # Success — record in GoTelegram config. Use "template_id" (canonical + # field name written by install.sh/save_gotelegram_config). config = load_json(GOTELEGRAM_CONFIG) or {} - config["template"] = tpl_id + config["template_id"] = tpl_id config["template_source"] = url save_json(GOTELEGRAM_CONFIG, config) await update.message.reply_text( diff --git a/install.sh b/install.sh index a9c5d10..fa1c0cf 100755 --- a/install.sh +++ b/install.sh @@ -1168,17 +1168,20 @@ bot_emit_json() { printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra" } -# Update a single key in config.json without rewriting the whole file +# Update a single key in config.json without rewriting the whole file. +# Uses `date -Iseconds` rather than jq's `now | todate` — the latter requires +# jq 1.6+ which is not available on Debian 10 or older CentOS. bot_update_config_field() { local key="$1" local value="$2" if [ ! -f "$GOTELEGRAM_CONFIG" ]; then return 1 fi - local tmp + local tmp now tmp=$(mktemp) || return 1 - if jq --arg k "$key" --arg v "$value" \ - '.[$k] = $v | .updated_at = (now | todate)' \ + now=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z) + if jq --arg k "$key" --arg v "$value" --arg t "$now" \ + '.[$k] = $v | .updated_at = $t' \ "$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then mv "$tmp" "$GOTELEGRAM_CONFIG" chmod 600 "$GOTELEGRAM_CONFIG"