Security fixes, ready-to-deploy image, and rewritten docs

Audit fixes:

- Wallet names were used unvalidated as file paths, so a name like
  "../<other-id>/x" reached another user's wallet directory or overwrote
  bot_settings.json. Names are now validated and every path is resolved and
  confirmed to sit inside the caller's own directory, which also blocks a
  planted symlink from escaping.
- Tor mode leaked: the mempool.space fee check opened a direct clear-net
  connection, tying the server's IP to its Bitcoin activity. It now goes
  through Tor's SOCKS proxy to mempool's onion service, DNS included.
- The xpub message was left in the chat forever. It reveals the wallet's whole
  address and balance history, so it is now deleted like the password is.
- Inline buttons stay live on old messages, so a user removed from the
  allow-list could keep using them. Callbacks re-check authorisation, and
  auth_check now works for callback queries instead of raising on a null
  message.
- Wallet names echoed back in callback_data are confirmed against the user's
  real wallet list.
- Settings are written via temp file + atomic rename; a crash mid-write used
  to truncate the file and lose every user's wallet selection.
- Replies were sent against messages that had just been deleted; deletion is
  now best-effort and cannot abort the flow.

Deployment:

- Allow-list can come from ALLOWED_USERS instead of a bind-mounted file, so a
  deployment needs no files on disk. Both sources merge when both are present.
- A missing bind-mounted allowed_users.txt made Docker create a directory in
  its place and silently lock everyone out; the entrypoint now detects that
  and stops with an explanation.
- Standard mode honours ELECTRUM_SERVER, which previously only worked in Tor
  mode despite being documented.
- Added VERSION, push.sh, docker-compose.deploy.yml and .env.deploy.example
  for a two-file deploy off the published image.

Docs rewritten in English and Persian around the name Telegtrum, leading with
the security model and what it does not protect against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-09-01 16:34:26 +00:00
parent 468111a52a
commit b7a0a86be2
10 changed files with 615 additions and 438 deletions
+160 -29
View File
@@ -3,7 +3,9 @@ import io
import json
import logging
import os
import re
import subprocess
import tempfile
from decimal import Decimal, InvalidOperation
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, Update
@@ -27,9 +29,18 @@ logger = logging.getLogger(__name__)
TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
ALLOWED_USERS_FILE = os.environ.get("ALLOWED_USERS_FILE", "/app/allowed_users.txt")
# Comma/space separated IDs. Lets a deployment work with no bind-mounted file.
ALLOWED_USERS_ENV = os.environ.get("ALLOWED_USERS", "")
WALLETS_BASE = "/root/.electrum/wallets"
SETTINGS_FILE = "/root/.electrum/bot_settings.json"
# Fee lookup. In Tor mode the entrypoint points these at the mempool.space onion
# service and Tor's SOCKS port, so the fee check never touches the clear net.
MEMPOOL_API_URL = os.environ.get(
"MEMPOOL_API_URL", "https://mempool.space/api/v1/fees/recommended"
)
TOR_SOCKS = os.environ.get("TOR_SOCKS", "")
# ── Translations ──────────────────────────────────────────────────────────────
@@ -50,6 +61,11 @@ STRINGS: dict[str, dict[str, str]] = {
"Use the buttons below to get started."
),
"not_authorized": "You are not authorized to use this bot.",
"invalid_wallet_name": ("❌ Invalid wallet name.\n\n"
"Use 1–64 characters: letters, numbers, spaces, dot, dash or underscore. "
"It must start with a letter or number.\n\nTry again:"),
"wallet_exists": "❌ You already have a wallet named '{name}'. Choose another name:",
"xpub_received": "✅ Master public key received — your message was deleted for privacy.",
"cancelled": "Cancelled.",
"cancel_for_button": "Previous operation cancelled. Tap the button again.",
"your_wallets": "Your wallets:",
@@ -146,6 +162,11 @@ STRINGS: dict[str, dict[str, str]] = {
"از دکمه‌های زیر شروع کنید."
),
"not_authorized": "شما مجاز به استفاده از این ربات نیستید.",
"invalid_wallet_name": ("❌ نام کیف‌پول نامعتبر است.\n\n"
"از ۱ تا ۶۴ کاراکتر استفاده کنید: حروف، اعداد، فاصله، نقطه، خط تیره یا زیرخط. "
"باید با حرف یا عدد شروع شود.\n\nدوباره تلاش کنید:"),
"wallet_exists": "❌ کیف‌پولی با نام '{name}' از قبل دارید. نام دیگری انتخاب کنید:",
"xpub_received": "✅ کلید عمومی اصلی دریافت شد — پیام شما برای حفظ حریم خصوصی حذف شد.",
"cancelled": "لغو شد.",
"cancel_for_button": "عملیات قبلی لغو شد. دوباره دکمه را فشار دهید.",
"your_wallets": "کیف پول‌های شما:",
@@ -248,9 +269,24 @@ def _load_settings() -> dict:
def _save_settings(settings: dict) -> None:
os.makedirs(os.path.dirname(SETTINGS_FILE), exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings, f)
# Written via a temp file + atomic rename: a crash half-way through can
# otherwise leave a truncated settings file, losing every user's wallet
# selection and language.
d = os.path.dirname(SETTINGS_FILE)
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d, prefix=".bot_settings.", suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(settings, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, SETTINGS_FILE)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def get_user_unit(user_id: int) -> str:
@@ -388,6 +424,10 @@ S_MENU, S_FEE_SRC, S_RATE, S_PASS, S_ADDR, S_AMT, S_BCAST = range(7)
def load_allowed_users() -> set[int]:
users: set[int] = set()
for chunk in re.split(r"[,\s]+", ALLOWED_USERS_ENV):
chunk = chunk.strip()
if chunk.isdigit():
users.add(int(chunk))
try:
with open(ALLOWED_USERS_FILE) as f:
for line in f:
@@ -397,8 +437,11 @@ def load_allowed_users() -> set[int]:
uid = line.split(",", 1)[0].strip()
if uid.isdigit():
users.add(int(uid))
except FileNotFoundError:
logger.warning("Allowed users file not found: %s", ALLOWED_USERS_FILE)
except (FileNotFoundError, IsADirectoryError, PermissionError):
# Not fatal — ALLOWED_USERS may be supplying the list instead
logger.warning("Allowed users file unreadable: %s", ALLOWED_USERS_FILE)
if not users:
logger.warning("No allowed users configured — the bot will reject everyone")
return users
@@ -408,8 +451,32 @@ def user_wallet_path(user_id: int) -> str:
return path
# Wallet names become file names. Without this a name like "../<other-id>/x"
# would read or overwrite another user's wallet, or clobber bot_settings.json.
WALLET_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
def is_valid_wallet_name(name: str) -> bool:
return bool(name) and bool(WALLET_NAME_RE.fullmatch(name)) and "/" not in name
def wallet_file(user_id: int, name: str) -> str | None:
"""Absolute path of a wallet, or None if the name escapes the user's dir."""
if not is_valid_wallet_name(name):
return None
base = os.path.realpath(user_wallet_path(user_id))
path = os.path.realpath(os.path.join(base, name))
# Belt and braces: even a name that passed the regex must resolve inside base
if path != base and not path.startswith(base + os.sep):
logger.warning("Rejected wallet path outside user dir: user=%s name=%r", user_id, name)
return None
return path
def list_user_wallets(user_id: int) -> list[str]:
return sorted(os.listdir(user_wallet_path(user_id)))
return sorted(
w for w in os.listdir(user_wallet_path(user_id)) if is_valid_wallet_name(w)
)
def run_electrum(*args: str) -> tuple[str, str, int]:
@@ -420,11 +487,26 @@ def run_electrum(*args: str) -> tuple[str, str, int]:
async def auth_check(update: Update) -> bool:
"""Gate every entry point. Also called from callback handlers, where
update.message is None, so the reply has to go through the query."""
uid = update.effective_user.id
if uid not in load_allowed_users():
if uid in load_allowed_users():
return True
if update.callback_query:
await update.callback_query.answer(t(uid, "not_authorized"), show_alert=True)
elif update.message:
await update.message.reply_text(t(uid, "not_authorized"))
return False
return True
return False
async def _delete_quietly(message) -> None:
"""Best-effort removal of a message holding a secret. Telegram refuses for
messages older than 48h, and the secret is still worth scrubbing when it
works, so a failure here must not abort the flow."""
try:
await message.delete()
except Exception as e: # noqa: BLE001 - never let cleanup break the conversation
logger.warning("Could not delete sensitive message: %s", e)
async def reply(update: Update, text: str, **kwargs) -> None:
@@ -484,7 +566,10 @@ async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
return
unit = get_user_unit(uid)
lang = get_user_lang(uid)
wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}"
wallet_path = wallet_file(uid, get_loaded_wallet(uid))
if wallet_path is None:
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
return
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
if rc != 0:
await update.message.reply_text(t(uid, "error", error=stderr or stdout), reply_markup=MAIN_KEYBOARD)
@@ -499,6 +584,8 @@ async def balance_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
query = update.callback_query
await query.answer()
uid = query.from_user.id
@@ -509,7 +596,10 @@ async def unit_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
if not loaded:
await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
return
wallet_path = f"{user_wallet_path(uid)}/{loaded}"
wallet_path = wallet_file(uid, loaded)
if wallet_path is None:
await query.edit_message_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML)
return
stdout, stderr, rc = run_electrum("getbalance", "-w", wallet_path)
if rc != 0:
await query.edit_message_text(t(uid, "error", error=stderr or stdout))
@@ -530,7 +620,10 @@ async def receive_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
if not loaded:
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
return
wallet_path = f"{user_wallet_path(uid)}/{loaded}"
wallet_path = wallet_file(uid, loaded)
if wallet_path is None:
await update.message.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
return
stdout, stderr, rc = run_electrum("getunusedaddress", "-w", wallet_path)
msg = stdout if rc == 0 else t(uid, "error", error=stderr or stdout)
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
@@ -544,6 +637,8 @@ async def lang_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
async def lang_switch(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not await auth_check(update):
return
query = update.callback_query
await query.answer()
uid = query.from_user.id
@@ -563,36 +658,53 @@ async def restore_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i
async def restore_got_name(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["walletname"] = update.message.text.strip()
uid = update.effective_user.id
uid = update.effective_user.id
name = update.message.text.strip()
if not is_valid_wallet_name(name):
await update.message.reply_text(t(uid, "invalid_wallet_name"))
return RESTORE_NAME
if name in list_user_wallets(uid):
await update.message.reply_text(t(uid, "wallet_exists", name=name))
return RESTORE_NAME
context.user_data["walletname"] = name
await update.message.reply_text(t(uid, "enter_xpub"))
return RESTORE_XPUB
async def restore_got_pubkey(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["pubkey"] = update.message.text.strip()
uid = update.effective_user.id
await update.message.reply_text(t(uid, "enter_password"))
context.user_data["pubkey"] = update.message.text.strip()
# The xpub reveals every address and the full balance history of the wallet.
# Don't leave it sitting in the Telegram chat forever.
await _delete_quietly(update.message)
await context.bot.send_message(update.effective_chat.id, t(uid, "xpub_received"))
await context.bot.send_message(update.effective_chat.id, t(uid, "enter_password"))
return RESTORE_PASSWORD
async def restore_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
uid = update.effective_user.id
password = update.message.text.strip()
await update.message.delete()
await _delete_quietly(update.message)
if not validate_password(password):
await context.bot.send_message(update.effective_chat.id, t(uid, "password_invalid"))
return RESTORE_PASSWORD
context.user_data["password"] = password
walletname = context.user_data["walletname"]
wallet_path = f"{user_wallet_path(uid)}/{walletname}"
await update.message.reply_text(t(uid, "restoring", name=walletname))
wallet_path = wallet_file(uid, walletname)
if wallet_path is None:
await context.bot.send_message(update.effective_chat.id, t(uid, "invalid_wallet_name"),
reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
return ConversationHandler.END
# The originating message is gone, so send rather than reply
await context.bot.send_message(update.effective_chat.id, t(uid, "restoring", name=walletname))
stdout, stderr, rc = run_electrum(
"restore", "--password", context.user_data["password"],
"--encrypt_file", "true", "-w", wallet_path, context.user_data["pubkey"],
)
msg = t(uid, "wallet_restored", name=walletname) if rc == 0 else t(uid, "error", error=stderr or stdout)
await update.message.reply_text(msg, reply_markup=MAIN_KEYBOARD)
await context.bot.send_message(update.effective_chat.id, msg, reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
return ConversationHandler.END
@@ -618,7 +730,13 @@ async def op_start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
async def op_wallet_selected(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
await update.callback_query.answer()
return await _wallet_selected(update, context, update.callback_query.data)
uid = update.effective_user.id
name = update.callback_query.data
# callback_data is echoed back by the client — confirm it names a real wallet
if name not in list_user_wallets(uid):
await update.callback_query.edit_message_text(t(uid, "invalid_wallet_name"))
return ConversationHandler.END
return await _wallet_selected(update, context, name)
async def _wallet_selected(
@@ -634,7 +752,7 @@ async def _wallet_selected(
async def op_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["password"] = update.message.text.strip()
await update.message.delete()
await _delete_quietly(update.message)
return await _execute_op(update, context)
@@ -642,7 +760,11 @@ async def _execute_op(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int
uid = update.effective_user.id
op = context.user_data.get("op", "")
walletname = context.user_data.get("walletname", "")
wallet_path = f"{user_wallet_path(uid)}/{walletname}"
wallet_path = wallet_file(uid, walletname)
if wallet_path is None:
await reply(update, t(uid, "invalid_wallet_name"))
context.user_data.clear()
return ConversationHandler.END
cmd_map = {
"load": ("load_wallet", "-w", wallet_path, "--password", context.user_data.get("password", "")),
"close": ("close_wallet", "-w", wallet_path),
@@ -711,10 +833,15 @@ async def fee_mempool(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int
await query.answer()
uid = query.from_user.id
try:
res = subprocess.run(
["curl", "-sSL", "https://mempool.space/api/v1/fees/recommended"],
capture_output=True, text=True, timeout=10,
)
cmd = ["curl", "-sSL", "--max-time", "15"]
if TOR_SOCKS:
# --socks5-hostname keeps DNS inside Tor too, so the lookup never
# leaks either. Set by the Tor entrypoint.
cmd += ["--socks5-hostname", TOR_SOCKS]
cmd.append(MEMPOOL_API_URL)
res = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
if res.returncode != 0:
raise RuntimeError(res.stderr.strip() or "fee lookup failed")
d = json.loads(res.stdout)
msg = t(uid, "fee_mempool_msg",
fastest=d["fastestFee"], half_hour=d["halfHourFee"],
@@ -745,7 +872,7 @@ async def send_got_feerate(update: Update, context: ContextTypes.DEFAULT_TYPE) -
async def send_got_password(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
context.user_data["password"] = update.message.text.strip()
uid = update.effective_user.id
await update.message.delete()
await _delete_quietly(update.message)
await context.bot.send_message(update.effective_chat.id, t(uid, "enter_addr"))
return S_ADDR
@@ -782,7 +909,11 @@ async def _build_tx(update: Update, context: ContextTypes.DEFAULT_TYPE, btc_amou
notify = update.callback_query.message if update.callback_query else update.message
await notify.reply_text(t(uid, "creating_tx"))
wallet_path = f"{user_wallet_path(uid)}/{get_loaded_wallet(uid)}"
wallet_path = wallet_file(uid, get_loaded_wallet(uid))
if wallet_path is None:
await notify.reply_text(t(uid, "wallet_not_loaded"), parse_mode=ParseMode.HTML, reply_markup=MAIN_KEYBOARD)
context.user_data.clear()
return ConversationHandler.END
stdout, stderr, rc = run_electrum(
"payto",
"-w", wallet_path,