From 02c6c3a9c653d41674511e6e21fcc21ac61d2d93 Mon Sep 17 00:00:00 2001
From: BlindMaster24 <375291171150z@gmail.com>
Date: Sun, 13 Sep 2026 12:46:58 +0300
Subject: [PATCH] fix(tgbot): render the add-client draft as HTML and escape
its values (#6492)
The draft is sent with ParseMode HTML but was written in Markdown, so every
card showed literal asterisks and backticks while the rest of the bot's
messages render properly. Its admin-supplied values (email, comment, TG id,
inbound remarks) were also interpolated raw, and a single '<' in any of them
makes Telegram reject the whole message as unparsable.
The wizard's own email and comment prompts echo those same draft values into
HTML-parsed messages and were escaped too: the card is deleted before the
prompt goes out, so a rejected prompt left the admin with an empty screen.
---
internal/web/service/tgbot/tgbot_client.go | 11 +-
.../service/tgbot/tgbot_draft_render_test.go | 148 ++++++++++++++++++
internal/web/service/tgbot/tgbot_router.go | 6 +-
3 files changed, 157 insertions(+), 8 deletions(-)
create mode 100644 internal/web/service/tgbot/tgbot_draft_render_test.go
diff --git a/internal/web/service/tgbot/tgbot_client.go b/internal/web/service/tgbot/tgbot_client.go
index 243ce08ad..d6ff9f01c 100644
--- a/internal/web/service/tgbot/tgbot_client.go
+++ b/internal/web/service/tgbot/tgbot_client.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "html"
"io"
"net/http"
"slices"
@@ -73,14 +74,14 @@ func (t *Tgbot) BuildClientDraftMessage() string {
}
var b strings.Builder
- b.WriteString("📝 *New client draft*\r\n")
- fmt.Fprintf(&b, "📧 Email: `%s`\r\n", client_Email)
- fmt.Fprintf(&b, "🔗 Attached: %s\r\n", attached)
+ b.WriteString("📝 New client draft\r\n")
+ fmt.Fprintf(&b, "📧 Email: %s\r\n", html.EscapeString(client_Email))
+ fmt.Fprintf(&b, "🔗 Attached: %s\r\n", html.EscapeString(attached))
fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit)
- fmt.Fprintf(&b, "👤 TG user: %s\r\n", tgID)
- fmt.Fprintf(&b, "💬 Comment: %s\r\n", comment)
+ fmt.Fprintf(&b, "👤 TG user: %s\r\n", html.EscapeString(tgID))
+ fmt.Fprintf(&b, "💬 Comment: %s\r\n", html.EscapeString(comment))
return b.String()
}
diff --git a/internal/web/service/tgbot/tgbot_draft_render_test.go b/internal/web/service/tgbot/tgbot_draft_render_test.go
new file mode 100644
index 000000000..05ce3dff0
--- /dev/null
+++ b/internal/web/service/tgbot/tgbot_draft_render_test.go
@@ -0,0 +1,148 @@
+package tgbot
+
+import (
+ "encoding/json"
+ "html"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/mhsanaei/3x-ui/v3/internal/web/locale"
+
+ "github.com/mymmrac/telego"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+ "golang.org/x/text/language"
+)
+
+// Regression test: the draft is sent with ParseMode HTML, so Markdown markers
+// were rendered literally and an unescaped value could break the whole message.
+func TestClientDraftMessageRendersHTML(t *testing.T) {
+ origEmail, origComment, origTgID := client_Email, client_Comment, client_TgID
+ origTotalGB, origLimitIP, origExpiry := client_TotalGB, client_LimitIP, client_ExpiryTime
+ origInboundIDs := receiver_inbound_IDs
+ t.Cleanup(func() {
+ client_Email, client_Comment, client_TgID = origEmail, origComment, origTgID
+ client_TotalGB, client_LimitIP, client_ExpiryTime = origTotalGB, origLimitIP, origExpiry
+ receiver_inbound_IDs = origInboundIDs
+ })
+
+ client_Email = "a@b.c"
+ client_Comment = "promo & <10 GB>"
+ client_TgID = "42"
+ client_TotalGB, client_LimitIP, client_ExpiryTime = 0, 0, 0
+ receiver_inbound_IDs = nil
+
+ out := (&Tgbot{}).BuildClientDraftMessage()
+
+ if !strings.Contains(out, "New client draft") {
+ t.Errorf("draft title is not HTML markup: %q", out)
+ }
+ if strings.Contains(out, "*New client draft*") || strings.Contains(out, "`") {
+ t.Errorf("draft still carries Markdown markers: %q", out)
+ }
+ if strings.Contains(out, "promo") {
+ t.Errorf("raw comment markup reached the message: %q", out)
+ }
+ if !strings.Contains(out, html.EscapeString(client_Comment)) {
+ t.Errorf("comment is not HTML-escaped: %q", out)
+ }
+}
+
+// botPromptLocalizer renders the two prompts the callback tests drive, with the
+// templates the translation files carry; without it I18n returns the bare key.
+func botPromptLocalizer(t *testing.T) {
+ t.Helper()
+ bundle := i18n.NewBundle(language.MustParse("en-US"))
+ bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
+ _ = bundle.AddMessages(language.MustParse("en-US"),
+ &i18n.Message{ID: "tgbot.messages.email_prompt", Other: "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email."},
+ &i18n.Message{ID: "tgbot.messages.comment_prompt", Other: "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment."},
+ )
+ orig := locale.LocalizerBot
+ t.Cleanup(func() { locale.LocalizerBot = orig })
+ locale.LocalizerBot = i18n.NewLocalizer(bundle, "en-US")
+}
+
+// promptTexts serves the methods these prompts touch and returns the text of
+// every sendMessage, so a test can check what Telegram would actually parse.
+func promptTexts(t *testing.T) (string, func() []string) {
+ t.Helper()
+ var mu sync.Mutex
+ var texts []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ result := any(true)
+ if r.URL.Path == "/bot"+testBotToken+"/sendMessage" {
+ var payload struct {
+ Text string `json:"text"`
+ }
+ _ = json.Unmarshal(body, &payload)
+ mu.Lock()
+ texts = append(texts, payload.Text)
+ mu.Unlock()
+ result = map[string]any{"message_id": 1, "date": 0, "chat": map[string]any{"id": 1, "type": "private"}}
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": result})
+ }))
+ t.Cleanup(srv.Close)
+
+ return srv.URL, func() []string {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]string(nil), texts...)
+ }
+}
+
+// Regression test: the wizard's own prompts are HTML-parsed as well, so the
+// draft value they echo has to be escaped exactly like the draft card.
+func TestAddClientPromptsEscapeDraftValues(t *testing.T) {
+ botPromptLocalizer(t)
+ url, texts := promptTexts(t)
+ swapTestBot(t, url)
+
+ origEmail, origComment := client_Email, client_Comment
+ origRunning := isRunning
+ t.Cleanup(func() {
+ client_Email, client_Comment = origEmail, origComment
+ isRunning = origRunning
+ })
+ isRunning = true
+
+ cases := []struct {
+ name string
+ data string
+ value string
+ }{
+ {"email prompt", "add_client_ch_default_email", "long@example.com"},
+ {"comment prompt", "add_client_ch_default_comment", "promo tag"},
+ }
+ tb := &Tgbot{}
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ client_Email, client_Comment = tc.value, tc.value
+
+ tb.answerCallback(&telego.CallbackQuery{
+ ID: "q1",
+ From: telego.User{ID: 1},
+ Data: tc.data,
+ Message: &telego.Message{Chat: telego.Chat{ID: 1}},
+ }, true) // admin
+
+ sent := texts()
+ if len(sent) == 0 {
+ t.Fatalf("no prompt was sent for %s", tc.data)
+ }
+ got := sent[len(sent)-1]
+ if strings.Contains(got, tc.value) {
+ t.Errorf("prompt = %q, want the draft value escaped", got)
+ }
+ if !strings.Contains(got, html.EscapeString(tc.value)) {
+ t.Errorf("prompt = %q, want it to contain %q", got, html.EscapeString(tc.value))
+ }
+ })
+ }
+}
diff --git a/internal/web/service/tgbot/tgbot_router.go b/internal/web/service/tgbot/tgbot_router.go
index 73e4d1a30..a9bcd8d63 100644
--- a/internal/web/service/tgbot/tgbot_router.go
+++ b/internal/web/service/tgbot/tgbot_router.go
@@ -1061,7 +1061,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
- prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+client_Email)
+ prompt_message := t.I18nBot("tgbot.messages.email_prompt", "ClientEmail=="+html.EscapeString(client_Email))
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
case "add_client_ch_default_comment":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -1071,7 +1071,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
- prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+client_Comment)
+ prompt_message := t.I18nBot("tgbot.messages.comment_prompt", "ClientComment=="+html.EscapeString(client_Comment))
t.SendMsgToTgbot(chatId, prompt_message, cancel_btn_markup)
case "add_client_ch_default_tg_id":
t.deleteMessageTgBot(chatId, callbackQuery.Message.GetMessageID())
@@ -1085,7 +1085,7 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if current == "" {
current = "—"
}
- t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send `-` / `none` to clear.\nCurrent: `%s`", current), cancel_btn_markup)
+ t.SendMsgToTgbot(chatId, fmt.Sprintf("Send the Telegram user id (numeric) to attach to this client, or send - / none to clear.\nCurrent: %s", html.EscapeString(current)), cancel_btn_markup)
case "add_client_ch_default_traffic":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(