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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user