From d63583caafbe00cb1b64db376821df4251b15373 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:36:37 +0100 Subject: [PATCH] fix(tgbot): keep a login alert's username on one line The Telegram login alert is a list of "label: value" lines, and the username, whatever the visitor typed on the login form, was inserted with its line breaks intact. Text after a break read as the panel's own IP and time lines, letting a failed login forge the alert. Control characters and Unicode line separators in the username become spaces, in the failed and the successful alert alike. --- internal/web/service/tgbot/tgbot_event.go | 16 ++++++++-- ...es_test.go => tgbot_reports_usage_test.go} | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) rename internal/web/service/tgbot/{tgbot_review_fixes_test.go => tgbot_reports_usage_test.go} (87%) diff --git a/internal/web/service/tgbot/tgbot_event.go b/internal/web/service/tgbot/tgbot_event.go index f3acc6c28..9046645df 100644 --- a/internal/web/service/tgbot/tgbot_event.go +++ b/internal/web/service/tgbot/tgbot_event.go @@ -5,6 +5,7 @@ import ( "os" "strconv" "strings" + "unicode" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" ) @@ -64,6 +65,17 @@ func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool { return false } +// oneLine keeps a value the panel did not write itself on its own line of an +// alert: control characters and Unicode line separators become spaces. +func oneLine(s string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) || r == '
' || r == '
' { + return ' ' + } + return r + }, s) +} + func (t *Tgbot) formatEventMessage(e eventbus.Event) string { host := getHostname() header := fmt.Sprintf("πŸ“‘ %s\n", host) @@ -147,7 +159,7 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string { if data.Status == "success" { msg := t.I18nBot("tgbot.messages.loginSuccess") msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host) - msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.username", "Username=="+oneLine(data.Username)) msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) return msg @@ -157,7 +169,7 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string { if data.Reason != "" { msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason) } - msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username) + msg += t.I18nBot("tgbot.messages.username", "Username=="+oneLine(data.Username)) msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP) msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time) return msg diff --git a/internal/web/service/tgbot/tgbot_review_fixes_test.go b/internal/web/service/tgbot/tgbot_reports_usage_test.go similarity index 87% rename from internal/web/service/tgbot/tgbot_review_fixes_test.go rename to internal/web/service/tgbot/tgbot_reports_usage_test.go index 75dfe3fce..e30cae594 100644 --- a/internal/web/service/tgbot/tgbot_review_fixes_test.go +++ b/internal/web/service/tgbot/tgbot_reports_usage_test.go @@ -15,6 +15,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mymmrac/telego" + "github.com/nicksnyder/go-i18n/v2/i18n" "gorm.io/gorm" ) @@ -334,3 +335,33 @@ func TestClientUsageDoesNotLoadTheInboundPerClient(t *testing.T) { t.Fatalf("the inbound row was loaded %d times for %d clients", wholeRows, owned) } } + +// The login alert is one field per line and the username is +// whatever the visitor typed, so a line break in it must not start a new line. +func TestLoginAlertKeepsUsernameOnOneLine(t *testing.T) { + draftLocalizer(t, + &i18n.Message{ID: "tgbot.messages.loginSuccess", Other: "βœ… Logged in to the panel successfully.\r\n"}, + &i18n.Message{ID: "tgbot.messages.loginFailed", Other: "❗️Login attempt to the panel failed.\r\n"}, + &i18n.Message{ID: "tgbot.messages.hostname", Other: "πŸ’» Host: {{ .Hostname }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.reason", Other: "❗️ Reason: {{ .Reason }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.username", Other: "πŸ‘€ Username: {{ .Username }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.ip", Other: "🌐 IP: {{ .IP }}\r\n"}, + &i18n.Message{ID: "tgbot.messages.time", Other: "⏰ Time: {{ .Time }}\r\n"}, + ) + + for _, lineBreak := range []string{"\r\n", "\n", "\r", "\v", "\f", "\u0085", "\u2028", "\u2029", "\r\n\r\n"} { + username := "mallory" + lineBreak + "🌐 IP: 203.0.113.77" + for _, status := range []string{"fail", "success"} { + got := (&Tgbot{}).formatEventMessage(eventbus.Event{ + Type: eventbus.EventLoginAttempt, + Data: &eventbus.LoginEventData{ + Username: username, IP: "192.0.2.1", Time: "now", Status: status, Reason: "invalid credentials", + }, + }) + want := "πŸ‘€ Username: mallory" + strings.Repeat(" ", len([]rune(lineBreak))) + "🌐 IP: 203.0.113.77\r\n🌐 IP: 192.0.2.1\r\n" + if !strings.Contains(got, want) { + t.Errorf("login %s, line break %q: message = %q, want it to contain %q", status, lineBreak, got, want) + } + } + } +}