Просмотр исходного кода

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.
BlindMaster24 13 часов назад
Родитель
Сommit
02c6c3a9c6

+ 6 - 5
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("📝 <b>New client draft</b>\r\n")
+	fmt.Fprintf(&b, "📧 Email: <code>%s</code>\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()
 }
 

+ 148 - 0
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 = "[email protected]"
+	client_Comment = "<b>promo</b> & <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, "<b>New client draft</b>") {
+		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, "<b>promo</b>") {
+		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<name>@example.com"},
+		{"comment prompt", "add_client_ch_default_comment", "promo <b>tag</b>"},
+	}
+	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))
+			}
+		})
+	}
+}

+ 3 - 3
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 <code>-</code> / <code>none</code> to clear.\nCurrent: <code>%s</code>", html.EscapeString(current)), cancel_btn_markup)
 	case "add_client_ch_default_traffic":
 		inlineKeyboard := tu.InlineKeyboard(
 			tu.InlineKeyboardRow(