fix(channels): harden all channels against unbounded read DoS
Systematic sweep across all channel implementations to cap unbounded io.ReadAll and io.Copy calls that allow remote memory or disk exhaustion. Inbound webhooks (4 MB cap, matching existing aibot.go pattern): - wecom/bot.go handleMessageCallback — io.LimitReader + 413 reject - wecom/app.go handleMessageCallback — io.LimitReader + 413 reject Outbound API responses (1 MB cap): - wecom/bot.go sendWeComMessage — error + success response reads - wecom/app.go uploadToWeComMedia, sendWeComMessage, refreshAccessToken - wecom/aibot.go postToResponseURL — error response read - line/line.go sendLineMessage — error response read Media downloads (50 MB cap, prevents disk exhaustion): - utils/media.go DownloadFile — io.CopyN replaces io.Copy, protects all 6 channels using this utility (telegram, slack, discord, line, feishu, onebot) - matrix/matrix.go downloadMedia — size check after DownloadBytes - feishu/feishu_64.go downloadResource — io.CopyN replaces io.Copy Fixes #1405
This commit is contained in:
parent
3bcbfd99b9
commit
c60f051cd9
7 changed files with 64 additions and 14 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -654,7 +654,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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -726,10 +726,18 @@ func (c *MatrixChannel) downloadMedia(
|
|||
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Reject oversized media after download. The mautrix SDK only exposes
|
||||
// DownloadBytes (no streaming variant), so we cannot prevent the initial
|
||||
// allocation. The 20s context timeout bounds the practical damage, and
|
||||
// this check ensures oversized payloads are not persisted to disk.
|
||||
const maxMediaSize = 50 << 20 // 50 MB
|
||||
data, err := c.client.DownloadBytes(reqCtx, parsed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) > maxMediaSize {
|
||||
return "", fmt.Errorf("matrix media exceeds %d MB size limit", maxMediaSize/(1<<20))
|
||||
}
|
||||
|
||||
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
|
||||
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,15 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
|
|||
})
|
||||
return ""
|
||||
}
|
||||
// Check if there is more data beyond the limit (oversized file).
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue