mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05: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{
|
||||
Title: tr("discord.commands.usageTitle", "Email=="+client.Email),
|
||||
Description: tr("discord.commands.usageDescription", "Remark=="+in.Remark, "Port=="+strconv.Itoa(in.Port)),
|
||||
Title: tr("discord.commands.usageTitle", "Email=="+escapeMarkdown(client.Email)),
|
||||
Description: tr("discord.commands.usageDescription", "Remark=="+escapeMarkdown(in.Remark), "Port=="+strconv.Itoa(in.Port)),
|
||||
Color: color,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Fields: []EmbedField{
|
||||
@@ -659,15 +659,17 @@ func (g *GatewayClient) sendInbounds(ctx context.Context) {
|
||||
if !in.Enable {
|
||||
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",
|
||||
"Protocol=="+string(in.Protocol),
|
||||
"Protocol=="+strings.ReplaceAll(string(in.Protocol), "`", "'"),
|
||||
"Port=="+strconv.Itoa(in.Port),
|
||||
"Clients=="+strconv.Itoa(len(in.ClientStats)),
|
||||
"Up=="+common.FormatTraffic(in.Up),
|
||||
"Down=="+common.FormatTraffic(in.Down),
|
||||
"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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
if 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) {
|
||||
settingService := setupTestDB(t)
|
||||
_ = settingService.SetDiscordBotEnable(true)
|
||||
|
||||
Reference in New Issue
Block a user