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.
This commit is contained in:
MHSanaei
2026-11-01 13:36:37 +01:00
parent 72fb4d9358
commit d63583caaf
2 changed files with 45 additions and 2 deletions
+14 -2
View File
@@ -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("<b>📡 %s</b>\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
@@ -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)
}
}
}
}