fix(v2.4.2): iter2 audit fixes

- 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)
This commit is contained in:
anten-ka
2026-04-10 13:30:47 +03:00
parent fc28a1a099
commit 724eeb92d9
2 changed files with 102 additions and 18 deletions

View File

@@ -240,6 +240,24 @@ async def sh(*args, timeout: int = 60) -> Tuple[int, str, str]:
return (-1, "", str(e)) 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 163 chars, labels separated by dots, alphanumerics + hyphens.
_DOMAIN_RE = re.compile(
r"^(?=.{1,253}$)(?:(?!-)[A-Za-z0-9-]{1,63}(?<!-)\.)+"
r"(?!-)[A-Za-z0-9-]{2,63}(?<!-)$"
)
async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict: async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict:
"""Invoke install.sh --action=X --json and parse the JSON result. """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 return False
async def safe_edit_message(query, text: str, reply_markup=None, parse_mode=None) -> bool: async def safe_edit_message(
"""Safely edit message, handling cases where message was deleted or not modified.""" 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: try:
await query.edit_message_text( await query.edit_message_text(text, **kwargs)
text, reply_markup=reply_markup, parse_mode=parse_mode
)
return True return True
except BadRequest as e: except BadRequest as e:
err_msg = str(e).lower() 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) config = load_json(GOTELEGRAM_CONFIG)
if config: if config:
lines.append(f"<b>{_t(user_id, 'status_mode')}:</b> {html.escape(str(config.get('mode', 'unknown')))}") lines.append(f"<b>{_t(user_id, 'status_mode')}:</b> {html.escape(str(config.get('mode', 'unknown')))}")
if "template" in config: # install.sh/save_gotelegram_config uses "template_id" (not "template")
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(config['template']))}") tpl = config.get("template_id") or config.get("template")
if "domain" in config: if tpl:
lines.append(f"<b>{_t(user_id, 'status_template')}:</b> {html.escape(str(tpl))}")
if config.get("domain"):
lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}") lines.append(f"<b>{_t(user_id, 'status_domain')}:</b> {html.escape(str(config['domain']))}")
if "port" in config: if config.get("port"):
lines.append(f"<b>{_t(user_id, 'status_port')}:</b> {html.escape(str(config['port']))}") lines.append(f"<b>{_t(user_id, 'status_port')}:</b> {html.escape(str(config['port']))}")
# Telemt config (v3: [server] port = ..., [censorship] tls_domain = ...) # 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") await query.answer("Invalid domain selection")
return 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() await query.answer()
config = load_json(GOTELEGRAM_CONFIG) or {} config = load_json(GOTELEGRAM_CONFIG) or {}
@@ -898,6 +936,18 @@ async def cb_lite_domain(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
return return
# Lite active — switch fake-TLS mask domain in place # Lite active — switch fake-TLS mask domain in place
if _BOT_ACTION_LOCK.locked():
await safe_edit_message(
query,
"<b>⏳ Другая операция уже выполняется</b>\n\n"
"Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
),
parse_mode="HTML",
)
return
progress_text = ( progress_text = (
"<b>⏳ Меняю маскировочный домен...</b>\n\n" "<b>⏳ Меняю маскировочный домен...</b>\n\n"
f"Новый домен: <code>{html.escape(domain)}</code>\n\n" f"Новый домен: <code>{html.escape(domain)}</code>\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") 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": if result.get("status") == "success":
text = ( text = (
@@ -1219,6 +1270,21 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await query.answer() 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,
"<b>❌ Некорректный идентификатор шаблона</b>\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 # Read current config to decide: in-place change-template vs fresh install
config = load_json(GOTELEGRAM_CONFIG) or {} config = load_json(GOTELEGRAM_CONFIG) or {}
current_mode = config.get("mode", "") current_mode = config.get("mode", "")
@@ -1240,6 +1306,18 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
return return
# Pro mode is active — perform change-template in place # Pro mode is active — perform change-template in place
if _BOT_ACTION_LOCK.locked():
await safe_edit_message(
query,
"<b>⏳ Другая операция уже выполняется</b>\n\n"
"Дождитесь завершения предыдущей смены шаблона/домена и повторите.",
reply_markup=InlineKeyboardMarkup(
[[InlineKeyboardButton(_t(_uid(update), "btn_back"), callback_data="menu_main")]]
),
parse_mode="HTML",
)
return
progress_text = ( progress_text = (
"<b>⏳ Меняю шаблон сайта...</b>\n\n" "<b>⏳ Меняю шаблон сайта...</b>\n\n"
f"Шаблон: <code>{html.escape(tpl_id)}</code>\n\n" f"Шаблон: <code>{html.escape(tpl_id)}</code>\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") await safe_edit_message(query, progress_text, parse_mode="HTML")
# Template download + git clone can be slow — generous timeout # Template download + git clone can be slow — generous timeout.
result = await run_bot_action("change-template", timeout=180, template=tpl_id) # 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": if result.get("status") == "success":
domain = result.get("domain", config.get("domain", "")) 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: if not ok:
await update.message.reply_text(_t(user_id, info), parse_mode="HTML") await update.message.reply_text(_t(user_id, info), parse_mode="HTML")
return 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 = load_json(GOTELEGRAM_CONFIG) or {}
config["template"] = tpl_id config["template_id"] = tpl_id
config["template_source"] = url config["template_source"] = url
save_json(GOTELEGRAM_CONFIG, config) save_json(GOTELEGRAM_CONFIG, config)
await update.message.reply_text( await update.message.reply_text(

View File

@@ -1168,17 +1168,20 @@ bot_emit_json() {
printf '{"status":"%s","message":"%s"%s}\n' "$status" "$msg_esc" "$extra" 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() { bot_update_config_field() {
local key="$1" local key="$1"
local value="$2" local value="$2"
if [ ! -f "$GOTELEGRAM_CONFIG" ]; then if [ ! -f "$GOTELEGRAM_CONFIG" ]; then
return 1 return 1
fi fi
local tmp local tmp now
tmp=$(mktemp) || return 1 tmp=$(mktemp) || return 1
if jq --arg k "$key" --arg v "$value" \ now=$(date -Iseconds 2>/dev/null || date +%Y-%m-%dT%H:%M:%S%z)
'.[$k] = $v | .updated_at = (now | todate)' \ if jq --arg k "$key" --arg v "$value" --arg t "$now" \
'.[$k] = $v | .updated_at = $t' \
"$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then "$GOTELEGRAM_CONFIG" > "$tmp" 2>/dev/null; then
mv "$tmp" "$GOTELEGRAM_CONFIG" mv "$tmp" "$GOTELEGRAM_CONFIG"
chmod 600 "$GOTELEGRAM_CONFIG" chmod 600 "$GOTELEGRAM_CONFIG"