diff --git a/internal/web/service/discord/gateway.go b/internal/web/service/discord/gateway.go index 6199ab15f..0559cb92c 100644 --- a/internal/web/service/discord/gateway.go +++ b/internal/web/service/discord/gateway.go @@ -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") diff --git a/internal/web/service/discord/gateway_inbounds_limits_test.go b/internal/web/service/discord/gateway_inbounds_limits_test.go index d40692c17..43e34e849 100644 --- a/internal/web/service/discord/gateway_inbounds_limits_test.go +++ b/internal/web/service/discord/gateway_inbounds_limits_test.go @@ -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) + } + }) +} diff --git a/internal/web/service/discord/subscriber.go b/internal/web/service/discord/subscriber.go index cad635632..1c493f606 100644 --- a/internal/web/service/discord/subscriber.go +++ b/internal/web/service/discord/subscriber.go @@ -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 = "-" diff --git a/internal/web/service/discord/subscriber_test.go b/internal/web/service/discord/subscriber_test.go index 0daba256b..e5f7fffca 100644 --- a/internal/web/service/discord/subscriber_test.go +++ b/internal/web/service/discord/subscriber_test.go @@ -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)