molecule-core/workspace-server/internal/channels/discord.go
Hongming Wang af9aae2c38 fix(security): cap webhook + config PATCH bodies (H3/H4)
Two HIGH-severity DoS surfaces: both handlers read the entire HTTP
body with io.ReadAll(r.Body) and no upper bound, so a caller streaming
a multi-gigabyte request could exhaust memory on the tenant instance
before we even validated the JSON.

H3 (Discord webhook): wrap Body in io.LimitReader with a 1 MiB cap.
Discord Interactions payloads are well under 10 KiB in practice.

H4 (workspace config PATCH): wrap Body in http.MaxBytesReader with a
256 KiB cap. Real configs are <10 KiB; jsonb handles the cap
comfortably. Returns 413 Request Entity Too Large on overflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:23:03 -07:00

222 lines
7.3 KiB
Go

package channels
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
discordWebhookPrefix = "https://discord.com/api/webhooks/"
discordHTTPTimeout = 10 * time.Second
)
// DiscordAdapter implements ChannelAdapter for Discord.
//
// Outbound messages are sent via Discord Incoming Webhooks. The webhook URL
// (https://discord.com/api/webhooks/{id}/{token}) is the only required config
// field — it encodes the channel and bot-token so no separate bot setup is
// needed for outbound-only use.
//
// Inbound messages are received via Discord's Interactions endpoint (slash
// commands and message components). Discord POSTs a signed JSON payload to the
// configured Interactions URL; ParseWebhook extracts the text and returns a
// standardized InboundMessage. Signature verification must be performed at
// the router layer before calling ParseWebhook.
//
// StartPolling returns nil immediately — Discord does not support long-polling;
// use the Interactions webhook route instead.
type DiscordAdapter struct{}
func (d *DiscordAdapter) Type() string { return "discord" }
func (d *DiscordAdapter) DisplayName() string { return "Discord" }
// ValidateConfig checks that the channel config contains a valid Discord
// Incoming Webhook URL. Returns a human-readable error for the Canvas UI.
func (d *DiscordAdapter) ValidateConfig(config map[string]interface{}) error {
webhookURL, _ := config["webhook_url"].(string)
if webhookURL == "" {
return fmt.Errorf("missing required field: webhook_url")
}
if !strings.HasPrefix(webhookURL, discordWebhookPrefix) {
return fmt.Errorf("invalid Discord webhook URL (must start with %s)", discordWebhookPrefix)
}
return nil
}
// SendMessage posts a text message to the configured Discord webhook.
// chatID is ignored — the destination channel is encoded in the webhook URL.
// Messages longer than 2000 characters are split into 2000-char chunks because
// Discord enforces a hard 2000-character limit per message.
func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]interface{}, _ string, text string) error {
webhookURL, _ := config["webhook_url"].(string)
if webhookURL == "" {
return fmt.Errorf("discord: webhook_url not configured")
}
if !strings.HasPrefix(webhookURL, discordWebhookPrefix) {
return fmt.Errorf("discord: invalid webhook URL")
}
const maxLen = 2000
// Split long messages into chunks at word boundaries where possible.
chunks := splitMessage(text, maxLen)
client := &http.Client{Timeout: discordHTTPTimeout}
for _, chunk := range chunks {
payload, err := json.Marshal(map[string]string{"content": chunk})
if err != nil {
return fmt.Errorf("discord: marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("discord: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
// Do NOT wrap err — the *url.Error from http.Client.Do includes the
// full request URL, which contains the Discord webhook token
// (https://discord.com/api/webhooks/{id}/{token}). Wrapping with %w
// would propagate that token into logs and error responses (#659).
return fmt.Errorf("discord: HTTP request failed")
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
// Discord returns 204 No Content on success.
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return fmt.Errorf("discord: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
}
return nil
}
// ParseWebhook handles a Discord Interactions POST.
// Discord sends two types of payloads: type 1 (PING) and type 2 (APPLICATION_COMMAND / slash command).
// Returns nil, nil for PING payloads — the handler layer must respond with `{"type":1}` to pass
// Discord's endpoint verification. Returns an InboundMessage for APPLICATION_COMMAND payloads.
func (d *DiscordAdapter) ParseWebhook(c *gin.Context, _ map[string]interface{}) (*InboundMessage, error) {
// Cap incoming webhook bodies at 1 MiB. Discord's Interactions API
// payloads are well under 10 KiB in practice; the cap is a DoS
// guard, not a functional limit.
const maxDiscordWebhook = 1 << 20
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxDiscordWebhook))
if err != nil {
return nil, fmt.Errorf("discord: read body: %w", err)
}
var payload struct {
Type int `json:"type"` // 1=PING, 2=APPLICATION_COMMAND, 3=MESSAGE_COMPONENT
ID string `json:"id"`
Data struct {
Name string `json:"name"` // slash command name
Options []struct {
Name string `json:"name"`
Value interface{} `json:"value"`
} `json:"options"`
} `json:"data"`
Member struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
} `json:"member"`
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
ChannelID string `json:"channel_id"`
Token string `json:"token"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("discord: parse interaction: %w", err)
}
// Type 1: PING from Discord during endpoint verification — let the handler layer respond.
if payload.Type == 1 {
return nil, nil
}
// Type 2 or 3: extract text from slash command name + options.
if payload.Type != 2 && payload.Type != 3 {
return nil, nil
}
// Reconstruct the invocation as text: "/command option1 option2"
var parts []string
if payload.Data.Name != "" {
parts = append(parts, "/"+payload.Data.Name)
}
for _, opt := range payload.Data.Options {
parts = append(parts, fmt.Sprintf("%v", opt.Value))
}
text := strings.TrimSpace(strings.Join(parts, " "))
if text == "" {
return nil, nil
}
// Prefer member.user (in guilds) over user (in DMs).
userID := payload.Member.User.ID
username := payload.Member.User.Username
if userID == "" {
userID = payload.User.ID
username = payload.User.Username
}
return &InboundMessage{
ChatID: payload.ChannelID,
UserID: userID,
Username: username,
Text: text,
MessageID: payload.ID,
Metadata: map[string]string{
"platform": "discord",
"interaction_token": payload.Token,
},
}, nil
}
// StartPolling returns nil immediately. Discord uses the Interactions endpoint
// (webhook-based) rather than long-polling for inbound messages.
func (d *DiscordAdapter) StartPolling(_ context.Context, _ map[string]interface{}, _ MessageHandler) error {
return nil
}
// splitMessage splits text into chunks of at most maxLen characters.
// It tries to break at the last newline or space within the window to avoid
// cutting words in the middle, but hard-splits if no boundary is found.
func splitMessage(text string, maxLen int) []string {
if len(text) <= maxLen {
return []string{text}
}
var chunks []string
for len(text) > 0 {
if len(text) <= maxLen {
chunks = append(chunks, text)
break
}
cut := maxLen
// Walk back from cut looking for a newline or space.
for i := cut - 1; i > maxLen/2; i-- {
if text[i] == '\n' || text[i] == ' ' {
cut = i + 1
break
}
}
chunks = append(chunks, text[:cut])
text = text[cut:]
}
return chunks
}