mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-10 23:25:02 +03:00
fix(discord): escape Markdown in alert text the panel did not write
Every Discord alert field went through cleanField, which only trimmed and truncated, so a login username, a node's error text, a remark or an email reached Discord as live Markdown: masked links, bold, headings, mentions. A failed login with a crafted username could post a working phishing link in the admins' channel. Foreign text is now Markdown-escaped in every field and command reply, and values shown in code spans can no longer close their span.
This commit is contained in:
@@ -610,8 +610,8 @@ func (g *GatewayClient) sendUsage(ctx context.Context, email string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
embed := Embed{
|
embed := Embed{
|
||||||
Title: tr("discord.commands.usageTitle", "Email=="+client.Email),
|
Title: tr("discord.commands.usageTitle", "Email=="+escapeMarkdown(client.Email)),
|
||||||
Description: tr("discord.commands.usageDescription", "Remark=="+in.Remark, "Port=="+strconv.Itoa(in.Port)),
|
Description: tr("discord.commands.usageDescription", "Remark=="+escapeMarkdown(in.Remark), "Port=="+strconv.Itoa(in.Port)),
|
||||||
Color: color,
|
Color: color,
|
||||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
Fields: []EmbedField{
|
Fields: []EmbedField{
|
||||||
@@ -659,15 +659,17 @@ func (g *GatewayClient) sendInbounds(ctx context.Context) {
|
|||||||
if !in.Enable {
|
if !in.Enable {
|
||||||
state = tr("disabled")
|
state = tr("disabled")
|
||||||
}
|
}
|
||||||
|
// The template puts the protocol in a code span, where a backslash escapes
|
||||||
|
// nothing and only a backtick gets out.
|
||||||
val := tr("discord.values.inbound",
|
val := tr("discord.values.inbound",
|
||||||
"Protocol=="+string(in.Protocol),
|
"Protocol=="+strings.ReplaceAll(string(in.Protocol), "`", "'"),
|
||||||
"Port=="+strconv.Itoa(in.Port),
|
"Port=="+strconv.Itoa(in.Port),
|
||||||
"Clients=="+strconv.Itoa(len(in.ClientStats)),
|
"Clients=="+strconv.Itoa(len(in.ClientStats)),
|
||||||
"Up=="+common.FormatTraffic(in.Up),
|
"Up=="+common.FormatTraffic(in.Up),
|
||||||
"Down=="+common.FormatTraffic(in.Down),
|
"Down=="+common.FormatTraffic(in.Down),
|
||||||
"State=="+state,
|
"State=="+state,
|
||||||
)
|
)
|
||||||
fields = append(fields, cleanField(fmt.Sprintf("📍 %s", in.Remark), val, false))
|
fields = append(fields, markupField(fmt.Sprintf("📍 %s", escapeMarkdown(in.Remark)), val, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
title := tr("discord.commands.inboundsTitle")
|
title := tr("discord.commands.inboundsTitle")
|
||||||
|
|||||||
@@ -216,3 +216,40 @@ func TestInboundsCommandRetriesARateLimitedPage(t *testing.T) {
|
|||||||
t.Errorf("posted %d times for %d messages, want one retry of the limited page", posts, len(msgs))
|
t.Errorf("posted %d times for %d messages, want one retry of the limited page", posts, len(msgs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCommandRepliesEscapeNodeReportedText(t *testing.T) {
|
||||||
|
settingService := setupTestDB(t)
|
||||||
|
_ = settingService.SetDiscordBotToken("test-bot-token")
|
||||||
|
_ = settingService.SetDiscordChannelId("ch-1")
|
||||||
|
_ = settingService.SetDiscordAdminIds("u1")
|
||||||
|
|
||||||
|
inbounds := inboundFixtures(1)
|
||||||
|
inbounds[0].Remark = "**x** [reset](https://attacker.example/)"
|
||||||
|
inbounds[0].Protocol = "vless` [reset](https://attacker.example/) `"
|
||||||
|
inbounds[0].ClientStats[0].Email = "[reset](https://attacker.example/)"
|
||||||
|
const remark = `\*\*x\*\* \[reset\](https\://attacker.example/)`
|
||||||
|
|
||||||
|
t.Run("inbounds", func(t *testing.T) {
|
||||||
|
msgs := runInboundsCommand(t, settingService, inbounds)
|
||||||
|
if len(msgs) != 1 || len(msgs[0].Embeds) != 1 || len(msgs[0].Embeds[0].Fields) != 1 {
|
||||||
|
t.Fatalf("expected one message with one field, got %+v", msgs)
|
||||||
|
}
|
||||||
|
field := msgs[0].Embeds[0].Fields[0]
|
||||||
|
if want := "📍 " + remark; field.Name != want {
|
||||||
|
t.Errorf("field name = %q, want %q", field.Name, want)
|
||||||
|
}
|
||||||
|
if want := "Protocol: `vless' [reset](https://attacker.example/) '` | Port: `10000`"; !strings.HasPrefix(field.Value, want) {
|
||||||
|
t.Errorf("field value = %q, want the protocol kept inside its code span: %q", field.Value, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("usage", func(t *testing.T) {
|
||||||
|
embed := runUsageCommand(t, settingService, inbounds, inbounds[0].ClientStats[0].Email).Embeds[0]
|
||||||
|
if want := `\[reset\](https\://attacker.example/)`; !strings.HasSuffix(embed.Title, ": "+want) {
|
||||||
|
t.Errorf("title = %q, want it to end in %q", embed.Title, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(embed.Description, "**"+remark+"**") {
|
||||||
|
t.Errorf("description = %q, want it to carry %q", embed.Description, remark)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,7 +98,27 @@ func truncateUnits(s string, maxUnits int) string {
|
|||||||
return b.String() + suffix
|
return b.String() + suffix
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markdownEscaper covers what Discord renders as markup in an embed field:
|
||||||
|
// formatting, headings, quotes, masked links, mentions and bare-URL auto-links.
|
||||||
|
var markdownEscaper = strings.NewReplacer(
|
||||||
|
`\`, `\\`, "`", "\\`", `*`, `\*`, `_`, `\_`, `~`, `\~`, `|`, `\|`, `#`, `\#`,
|
||||||
|
`[`, `\[`, `]`, `\]`, `<`, `\<`, `>`, `\>`, `://`, `\://`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// escapeMarkdown makes text the panel did not write itself (login usernames,
|
||||||
|
// node errors, remarks, emails) show up literally instead of as Markdown.
|
||||||
|
func escapeMarkdown(s string) string {
|
||||||
|
return markdownEscaper.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanField builds a field from plain text: Discord shows both strings literally.
|
||||||
func cleanField(name, value string, inline bool) EmbedField {
|
func cleanField(name, value string, inline bool) EmbedField {
|
||||||
|
return markupField(escapeMarkdown(name), escapeMarkdown(value), inline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// markupField keeps the caller's own Markdown, so whatever foreign text the
|
||||||
|
// caller puts into it has to be escaped beforehand.
|
||||||
|
func markupField(name, value string, inline bool) EmbedField {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = "-"
|
name = "-"
|
||||||
|
|||||||
@@ -252,6 +252,47 @@ func TestCleanField_Protection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatEmbed_ForeignTextIsNotMarkdown(t *testing.T) {
|
||||||
|
sub := NewSubscriber(setupTestDB(t), nil)
|
||||||
|
const text = "a_b\n**x** [reset](https://attacker.example/) <@1> `c`"
|
||||||
|
const want = "a\\_b\n\\*\\*x\\*\\* \\[reset\\](https\\://attacker.example/) \\<@1\\> \\`c\\`"
|
||||||
|
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
event eventbus.Event
|
||||||
|
field int
|
||||||
|
}{
|
||||||
|
"failed login username": {eventbus.Event{
|
||||||
|
Type: eventbus.EventLoginAttempt,
|
||||||
|
Data: &eventbus.LoginEventData{Username: text, IP: "2001:db8::1", Status: "fail", Time: "2026-10-15 02:52:22"},
|
||||||
|
}, 0},
|
||||||
|
"successful login username": {eventbus.Event{
|
||||||
|
Type: eventbus.EventLoginAttempt,
|
||||||
|
Data: &eventbus.LoginEventData{Username: text, IP: "2001:db8::1", Status: "success", Time: "2026-10-15 02:52:22"},
|
||||||
|
}, 0},
|
||||||
|
"node-reported xray error": {eventbus.Event{
|
||||||
|
Type: eventbus.EventNodeDown,
|
||||||
|
Source: "node-1",
|
||||||
|
Data: &eventbus.NodeHealthData{XrayError: text},
|
||||||
|
}, 1},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
embed, ok := sub.FormatEmbed(tc.event)
|
||||||
|
if !ok || len(embed.Fields) <= tc.field {
|
||||||
|
t.Fatalf("expected an alert with field %d, got ok=%v fields=%d", tc.field, ok, len(embed.Fields))
|
||||||
|
}
|
||||||
|
if got := embed.Fields[tc.field].Value; got != want {
|
||||||
|
t.Errorf("field value = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Only markup is escaped: the IP and time fields go out as written.
|
||||||
|
for _, f := range embed.Fields[tc.field+1:] {
|
||||||
|
if strings.Contains(f.Value, `\`) {
|
||||||
|
t.Errorf("field %q = %q, want it untouched", f.Name, f.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHandleEvent_EndToEndWithServer(t *testing.T) {
|
func TestHandleEvent_EndToEndWithServer(t *testing.T) {
|
||||||
settingService := setupTestDB(t)
|
settingService := setupTestDB(t)
|
||||||
_ = settingService.SetDiscordBotEnable(true)
|
_ = settingService.SetDiscordBotEnable(true)
|
||||||
|
|||||||
Reference in New Issue
Block a user