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(