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(