diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af..47fd08899 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -636,7 +636,9 @@ func (c *FeishuChannel) downloadResource( return "" } - if _, copyErr := io.Copy(out, resp.File); copyErr != nil { + maxSize := int64(utils.MaxMediaDownloadSize) + written, copyErr := io.CopyN(out, resp.File, maxSize) + if copyErr != nil && copyErr != io.EOF { out.Close() os.Remove(localPath) logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{ @@ -644,6 +646,14 @@ func (c *FeishuChannel) downloadResource( }) return "" } + if written >= maxSize { + out.Close() + os.Remove(localPath) + logger.ErrorCF("feishu", "Resource exceeds size limit, download aborted", map[string]any{ + "limit_mb": maxSize / (1 << 20), + }) + return "" + } out.Close() ref, err := store.Store(localPath, media.MediaMeta{ diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 56ba02183..154b31470 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -663,7 +663,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err)) } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index bec5dfdac..2e5dcef1c 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -765,10 +765,15 @@ func (c *MatrixChannel) downloadMedia( } }() - _, err = io.Copy(tmp, reader) - if err != nil { + // Limit media download to 50 MB to prevent disk exhaustion. + const maxMediaSize int64 = 50 << 20 // 50 MB + written, err := io.CopyN(tmp, reader, maxMediaSize) + if err != nil && err != io.EOF { return "", err } + if written >= maxMediaSize { + return "", fmt.Errorf("matrix media exceeds %d MB size limit", maxMediaSize/(1<<20)) + } if err = readerClose(); err != nil { return "", fmt.Errorf("decrypt matrix media: %w", err) } diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 93fe8c36d..c7bddd869 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -793,7 +793,7 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro return nil } - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err) } diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 2098fcd4e..e6db65de4 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -320,8 +320,9 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp } defer resp.Body.Close() + const maxRespSize = 1 << 20 // 1 MB if resp.StatusCode != http.StatusOK { - respBody, readErr := io.ReadAll(resp.Body) + respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxRespSize)) if readErr != nil { return "", channels.ClassifySendError( resp.StatusCode, @@ -379,8 +380,9 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri } defer resp.Body.Close() + const maxSendRespSize = 1 << 20 // 1 MB if resp.StatusCode != http.StatusOK { - respBody, readErr := io.ReadAll(resp.Body) + respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxSendRespSize)) if readErr != nil { return channels.ClassifySendError( resp.StatusCode, @@ -393,7 +395,7 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri ) } - respBody, err := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxSendRespSize)) if err != nil { return fmt.Errorf("failed to read response: %w", err) } @@ -550,13 +552,18 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp return } - // Read request body - body, err := io.ReadAll(r.Body) + // Read request body (limit to 4 MB to prevent memory exhaustion). + const maxBodySize = 4 << 20 // 4 MB + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close() + if len(body) > maxBodySize { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + return + } // Parse XML to get encrypted message var encryptedMsg struct { @@ -697,7 +704,7 @@ func (c *WeComAppChannel) refreshAccessToken() error { } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return fmt.Errorf("failed to read response: %w", err) } diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 96d5a961f..c2641b515 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -253,13 +253,18 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp return } - // Read request body - body, err := io.ReadAll(r.Body) + // Read request body (limit to 4 MB to prevent memory exhaustion). + const maxBodySize = 4 << 20 // 4 MB + body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1)) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close() + if len(body) > maxBodySize { + http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge) + return + } // Parse XML to get encrypted message var encryptedMsg struct { @@ -452,8 +457,9 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content } defer resp.Body.Close() + const maxRespSize = 1 << 20 // 1 MB if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxRespSize)) if readErr != nil { return channels.ClassifySendError( resp.StatusCode, @@ -466,7 +472,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content ) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxRespSize)) if err != nil { return fmt.Errorf("failed to read response: %w", err) } diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 3e1c5d88e..0b8af1937 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -48,12 +48,17 @@ func SanitizeFilename(filename string) string { return base } +// MaxMediaDownloadSize is the upper bound for media file downloads (50 MB). +// Prevents disk exhaustion from oversized or malicious attachments. +const MaxMediaDownloadSize = 50 << 20 + // DownloadOptions holds optional parameters for downloading files type DownloadOptions struct { Timeout time.Duration ExtraHeaders map[string]string LoggerPrefix string ProxyURL string + MaxSize int64 // 0 = use MaxMediaDownloadSize default } // DownloadFile downloads a file from URL to a local temp directory. @@ -134,7 +139,12 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string { } defer out.Close() - if _, err := io.Copy(out, resp.Body); err != nil { + maxSize := opts.MaxSize + if maxSize <= 0 { + maxSize = MaxMediaDownloadSize + } + written, err := io.CopyN(out, resp.Body, maxSize) + if err != nil && err != io.EOF { out.Close() os.Remove(localPath) logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{ @@ -142,6 +152,14 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string { }) return "" } + if written >= maxSize { + out.Close() + os.Remove(localPath) + logger.ErrorCF(opts.LoggerPrefix, "File exceeds size limit, download aborted", map[string]any{ + "limit_mb": maxSize / (1 << 20), + }) + return "" + } logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]any{ "path": localPath,