From 368e8bdd39605be4f489f8b0cf43e673ec800937 Mon Sep 17 00:00:00 2001 From: Ramil Valitov Date: Fri, 11 Sep 2026 00:25:55 +0300 Subject: [PATCH 1/2] fix(telegram): enforce the documented reseller role restrictions The README restricts a `reseller` to voucher redemption (/redeem), voucher batch generation (/mp_voucher create) and voucher inventory auditing (/mp_voucher list), with everything else "automatically blocked with security violation logging". None of that was true. _check_tg_role() can return superadmin, reseller or none, but _process_cmd only rejected none plus four superadmin-only commands. A reseller therefore reached nearly the whole Admin Control Plane, including /mp_secrets, /mp_add, /mp_rotate, /mp_setlimit, /mp_broadcast and /mp_help. The claimed violation logging did not exist anywhere in the repo. Gate the dispatcher on the reseller role immediately after the existing unauthenticated check, so non-voucher control plane commands are refused before reaching any handler. Placing it there also catches the four destructive commands, which previously fell through to the superadmin gates. Denials reply to the sender rather than the admin chat and are recorded in ${INSTALL_DIR}/audit.log. The daemon is self-contained and never sources the manager, so it cannot call the manager's audit_log(); _tg_security_log() writes the same line format inline instead. --- mtproxymax.sh | 29 +++++- tests/test_telegram_reseller_rbac.sh | 150 +++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 tests/test_telegram_reseller_rbac.sh diff --git a/mtproxymax.sh b/mtproxymax.sh index c398b7f..a5e9151 100644 --- a/mtproxymax.sh +++ b/mtproxymax.sh @@ -11554,6 +11554,19 @@ _check_tg_role() { echo "${r:-none}" } +# Record a security event in the shared audit log. The bot daemon is +# self-contained and never sources the manager, so it cannot call the manager's +# audit_log() and writes the same line format itself. +_tg_security_log() { + local cid="$1" action="$2" + local _log="${INSTALL_DIR}/audit.log" + mkdir -p "$INSTALL_DIR" 2>/dev/null || true + printf '%s UTC | telegram:%s | SECURITY: denied %q\n' \ + "$(date -u '+%Y-%m-%d %H:%M:%S')" "$cid" "$action" >> "$_log" 2>/dev/null || true + chmod 600 "$_log" 2>/dev/null || true + return 0 +} + _process_cmd() { local update_id="$1" chat_id="$2" text="$3" echo "$((update_id + 1))" > "$OFFSET_FILE" @@ -11640,7 +11653,21 @@ _process_cmd() { return fi - # Superadmin & Reseller administrative commands + # Resellers are limited to vouchers (see README: Role-Based Access Control). + # Everything else in the control plane is a privilege violation, so it is + # refused and recorded before it can reach any handler below. + if [ "$role" = "reseller" ]; then + case "$text" in + /mp_voucher|/mp_voucher@*|/mp_voucher\ *|/mp_voucher@*\ *) ;; + *) + tg_send_to "$chat_id" "⛔ Permission denied: the reseller role is limited to voucher commands." + _tg_security_log "$chat_id" "$text" + return + ;; + esac + fi + + # Administrative commands (resellers only ever reach /mp_voucher above) case "$text" in /mp_voucher\ *|/mp_voucher@*\ *) local sub=$(echo "$text" | awk '{print $2}') diff --git a/tests/test_telegram_reseller_rbac.sh b/tests/test_telegram_reseller_rbac.sh new file mode 100644 index 0000000..c8a50e4 --- /dev/null +++ b/tests/test_telegram_reseller_rbac.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Regression tests for reseller RBAC enforcement in the Telegram bot. +# +# The README restricts a `reseller` to voucher redemption and voucher +# create/list, but the dispatcher only blocked `role == none` plus four +# superadmin-only commands, so a reseller could drive nearly the whole admin +# control plane. These tests pin the documented contract. +set -o pipefail + +if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then + echo "SKIP: bash 4+ required (got ${BASH_VERSION:-unknown})" >&2 + exit 0 +fi + +TEST_TMPDIR=$(mktemp -d) +INSTALL_DIR="$TEST_TMPDIR/install" +mkdir -p "$INSTALL_DIR/relay_stats" +OFFSET_FILE="$INSTALL_DIR/relay_stats/tg_offset" +ADMINS_FILE="$INSTALL_DIR/admins.conf" +AUDIT_LOG="$INSTALL_DIR/audit.log" + +MTPROXYMAX_SOURCE_ONLY=true source "$(dirname "${BASH_SOURCE[0]}")/../mtproxymax.sh" +set +e +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +TESTS_RUN=0 +TESTS_FAILED=0 + +assert_contains() { + local name="$1" needle="$2" haystack="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + printf ' PASS %s\n' "$name" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf ' FAIL %s (missing %q in %q)\n' "$name" "$needle" "$haystack" + fi +} + +assert_not_contains() { + local name="$1" needle="$2" haystack="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + if printf '%s' "$haystack" | grep -qF -- "$needle"; then + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf ' FAIL %s (unexpected %q in %q)\n' "$name" "$needle" "$haystack" + else + printf ' PASS %s\n' "$name" + fi +} + +assert_eq() { + local name="$1" want="$2" got="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ "$got" = "$want" ]; then + printf ' PASS %s\n' "$name" + else + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf ' FAIL %s (got=%q want=%q)\n' "$name" "$got" "$want" + fi +} + +# ── Stubs ──────────────────────────────────────────────────────────────────── +REPLIES="$TEST_TMPDIR/replies.log" +ROLE_TO_RETURN="reseller" + +_check_tg_role() { echo "$ROLE_TO_RETURN"; } +tg_send() { printf 'admin|%s\n' "$*" >> "$REPLIES"; } +tg_send_to() { printf 'to:%s|%s\n' "$1" "$2" >> "$REPLIES"; } +load_tg_settings() { :; } +is_running() { return 1; } +log_warn() { :; } + +# Stand-in for the manager binary the voucher handler shells out to. +cat > "$INSTALL_DIR/mtproxymax" <<'EOS' +#!/bin/bash +case "$1 $2" in + # Three lines so the create path (which does `tail -n +3`) still yields one. + "voucher list") printf 'HEADER\nSEPARATOR\nMTP-AAAA-BBBB\n' ;; + "voucher create"|"voucher redeem") : ;; + *) : ;; +esac +EOS +chmod +x "$INSTALL_DIR/mtproxymax" + +# Exercise the exact dispatcher shipped in the generated bot daemon. +telegram_generate_service_script +DAEMON="$INSTALL_DIR/mtproxymax-telegram.sh" +awk '/^_tg_security_log\(\)/,/^}$/' "$DAEMON" > "$TEST_TMPDIR/daemon-fns.sh" +awk '/^_process_cmd\(\)/,/^}$/' "$DAEMON" >> "$TEST_TMPDIR/daemon-fns.sh" +assert_eq "daemon helper extraction is valid bash" 0 \ + "$(bash -n "$TEST_TMPDIR/daemon-fns.sh" 2>/dev/null; echo $?)" +source "$TEST_TMPDIR/daemon-fns.sh" + +# run -> replies in $REPLIES +run() { + : > "$REPLIES" + ROLE_TO_RETURN="$1" + _process_cmd 1 "$2" "$3" 2>/dev/null +} +audit_now() { cat "$AUDIT_LOG" 2>/dev/null || printf ''; } +reset_audit() { : > "$AUDIT_LOG"; } + +echo "Telegram reseller RBAC tests" + +# ── A reseller is limited to vouchers ──────────────────────────────────────── +reset_audit +run reseller 333 "/mp_status" +assert_contains "reseller is denied /mp_status" "Permission denied" "$(cat "$REPLIES")" +assert_contains "denial is logged" "SECURITY" "$(audit_now)" +assert_contains "log records the denied command" "/mp_status" "$(audit_now)" +assert_contains "log records the offending chat" "333" "$(audit_now)" +assert_contains "denial goes to the sender, not the admin chat" "to:333|" "$(cat "$REPLIES")" +assert_not_contains "denial is not sent to the admin chat" "admin|" "$(cat "$REPLIES")" + +for _cmd in /mp_restart /mp_lockdown /mp_update /mp_remove /mp_add /mp_broadcast \ + /mp_secrets /mp_link /mp_setlimit /mp_help /mp_traffic /reply; do + reset_audit + run reseller 333 "$_cmd" + assert_contains "reseller is denied $_cmd" "Permission denied" "$(cat "$REPLIES")" + assert_contains "denial of $_cmd is logged" "SECURITY" "$(audit_now)" +done + +# ── ...but vouchers and public commands still work ─────────────────────────── +for _cmd in "/mp_voucher list" "/mp_voucher create 5 10G 30"; do + reset_audit + run reseller 333 "$_cmd" + assert_not_contains "reseller is allowed $_cmd" "Permission denied" "$(cat "$REPLIES")" + assert_eq "allowed $_cmd is not logged as a violation" "" "$(audit_now)" + assert_contains "allowed $_cmd reaches the voucher engine" "MTP-AAAA-BBBB" "$(cat "$REPLIES")" +done + +reset_audit +run reseller 333 "/start" +assert_not_contains "reseller keeps the public /start" "Permission denied" "$(cat "$REPLIES")" +assert_contains "reseller gets the self-service welcome" "Welcome to MTProxyMax" "$(cat "$REPLIES")" + +# ── Superadmins are unaffected ─────────────────────────────────────────────── +reset_audit +run superadmin 111 "/mp_status" +assert_not_contains "superadmin is not denied /mp_status" "Permission denied" "$(cat "$REPLIES")" +assert_eq "superadmin action is not logged as a violation" "" "$(audit_now)" + +# ── Unknown users still get nothing ────────────────────────────────────────── +reset_audit +run none 999 "/mp_status" +assert_eq "unknown role gets no admin reply" "" "$(cat "$REPLIES")" +assert_eq "unknown role is not logged as a violation" "" "$(audit_now)" + +printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED" +[ "$TESTS_FAILED" -eq 0 ] From 2bd76a3094487f71c819bc4037a287d5a4269479 Mon Sep 17 00:00:00 2001 From: Ramil Valitov Date: Fri, 11 Sep 2026 00:25:55 +0300 Subject: [PATCH 2/2] fix(telegram): fail closed on unrecognised admin roles _check_tg_role() returns whatever admins.conf holds, and admins.conf is a plain file an operator can hand-edit. The dispatcher only special-cased "none" and "reseller", so any other value -- a typo like "SUPERADMIN", or a stale role such as "operator" -- fell through into the admin case and was granted the control plane, minus only the four superadmin-gated commands. Make the control plane an allowlist: superadmin and reseller are handled explicitly and everything else is refused and logged, naming the offending role so the misconfiguration is obvious. Roles are matched exactly, so a differently-cased value fails closed rather than open. --- README.md | 1 + mtproxymax.sh | 38 ++++++++++++++++++---------- tests/test_telegram_reseller_rbac.sh | 28 +++++++++++++++++--- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 5c7ca1d..ed37d33 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ Activating lockdown instantly engages the **Kernel SYN Shield**, activates **Ult - Configures role hierarchies stored in `${INSTALL_DIR}/admins.conf`: - **`superadmin`**: Full access to all 21 administrative commands, including destructive engine restarts (`/mp_restart`), emergency lockdowns (`/mp_lockdown`), bot removals (`/mp_remove`), and self-updates (`/mp_update`). - **`reseller`**: Delegated commercial management rights restricted to voucher redemption (`/redeem`), voucher batch generation (`/mp_voucher create `), and voucher inventory auditing (`/mp_voucher list`). Destructive engine commands are automatically blocked with security violation logging. + - **Any other value** is treated as a misconfiguration and refused rather than granted access, so a typo in `admins.conf` fails closed. - **Decoupled Self-Service Status Portal (`mtproxymax portal [enable|disable|port|generate|serve|status]`):** Lightweight, zero-dependency static web dashboard designed for client self-service and transparent uptime reporting. - Generates an ultra-responsive, modern dark-mode glassmorphism HTML page (`index.html`) stored in `${INSTALL_DIR}/portal/`. - During periodic engine sweeps (`sweep()`), MTProxyMax automatically exports real-time system metrics (`status.json`) and anonymized user leaderboard statistics (`users.json`). diff --git a/mtproxymax.sh b/mtproxymax.sh index a5e9151..08f67d5 100644 --- a/mtproxymax.sh +++ b/mtproxymax.sh @@ -11653,21 +11653,31 @@ _process_cmd() { return fi - # Resellers are limited to vouchers (see README: Role-Based Access Control). - # Everything else in the control plane is a privilege violation, so it is - # refused and recorded before it can reach any handler below. - if [ "$role" = "reseller" ]; then - case "$text" in - /mp_voucher|/mp_voucher@*|/mp_voucher\ *|/mp_voucher@*\ *) ;; - *) - tg_send_to "$chat_id" "⛔ Permission denied: the reseller role is limited to voucher commands." - _tg_security_log "$chat_id" "$text" - return - ;; - esac - fi + # The control plane is an allowlist. A reseller is limited to vouchers (see + # README: Role-Based Access Control). Any other role value is a + # misconfiguration — admins.conf is a plain file an operator can hand-edit — + # and is refused rather than silently granted administrative rights. + case "$role" in + superadmin) + ;; + reseller) + case "$text" in + /mp_voucher|/mp_voucher@*|/mp_voucher\ *|/mp_voucher@*\ *) ;; + *) + tg_send_to "$chat_id" "⛔ Permission denied: the reseller role is limited to voucher commands." + _tg_security_log "$chat_id" "$text" + return + ;; + esac + ;; + *) + tg_send_to "$chat_id" "⛔ Permission denied: this account has an unrecognised role." + _tg_security_log "$chat_id" "unrecognised role '${role}': ${text}" + return + ;; + esac - # Administrative commands (resellers only ever reach /mp_voucher above) + # Administrative commands (only superadmins reach this point) case "$text" in /mp_voucher\ *|/mp_voucher@*\ *) local sub=$(echo "$text" | awk '{print $2}') diff --git a/tests/test_telegram_reseller_rbac.sh b/tests/test_telegram_reseller_rbac.sh index c8a50e4..385b3db 100644 --- a/tests/test_telegram_reseller_rbac.sh +++ b/tests/test_telegram_reseller_rbac.sh @@ -140,11 +140,33 @@ run superadmin 111 "/mp_status" assert_not_contains "superadmin is not denied /mp_status" "Permission denied" "$(cat "$REPLIES")" assert_eq "superadmin action is not logged as a violation" "" "$(audit_now)" -# ── Unknown users still get nothing ────────────────────────────────────────── +# ── Unauthenticated users still get nothing ────────────────────────────────── reset_audit run none 999 "/mp_status" -assert_eq "unknown role gets no admin reply" "" "$(cat "$REPLIES")" -assert_eq "unknown role is not logged as a violation" "" "$(audit_now)" +assert_eq "unauthenticated user gets no admin reply" "" "$(cat "$REPLIES")" +assert_eq "unauthenticated user is not logged as a violation" "" "$(audit_now)" + +# ── Unrecognised roles fail closed ─────────────────────────────────────────── +# _check_tg_role returns whatever admins.conf holds, and admins.conf is a plain +# file an operator can hand-edit. Anything that is not exactly 'superadmin' or +# 'reseller' must be refused rather than granted the admin control plane. +for _role in operator administrator root SUPERADMIN superadmin2; do + reset_audit + run "$_role" 444 "/mp_status" + assert_contains "role '$_role' is denied the control plane" "Permission denied" "$(cat "$REPLIES")" + assert_contains "role '$_role' denial is logged" "SECURITY" "$(audit_now)" + assert_contains "role '$_role' denial names the role" "$_role" "$(audit_now)" +done + +reset_audit +run operator 444 "/mp_voucher list" +assert_contains "unrecognised role cannot reach the voucher engine" "Permission denied" "$(cat "$REPLIES")" + +reset_audit +run operator 444 "/start" +assert_not_contains "unrecognised role still gets public commands" "Permission denied" "$(cat "$REPLIES")" +assert_contains "unrecognised role gets the self-service welcome" "Welcome to MTProxyMax" "$(cat "$REPLIES")" +assert_eq "a public command is not logged as a violation" "" "$(audit_now)" printf '\n%d tests, %d failures\n' "$TESTS_RUN" "$TESTS_FAILED" [ "$TESTS_FAILED" -eq 0 ]