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 - wecom/app.go handleMessageCallback Outbound API responses (1 MB cap): - wecom/bot.go sendWeComMessage - wecom/app.go uploadToWeComMedia, sendWeComMessage, refreshAccessToken - wecom/aibot.go postToResponseURL - line/line.go sendLineMessage 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 — io.CopyN on streaming Download - feishu/feishu_64.go downloadResource — io.CopyN replaces io.Copy Fixes #1405
This commit is contained in:
parent
555af137b4
commit
934d855fd6
7 changed files with 62 additions and 16 deletions
|
|
@ -636,7 +636,9 @@ func (c *FeishuChannel) downloadResource(
|
||||||
return ""
|
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()
|
out.Close()
|
||||||
os.Remove(localPath)
|
os.Remove(localPath)
|
||||||
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
|
logger.ErrorCF("feishu", "Failed to write resource to file", map[string]any{
|
||||||
|
|
@ -644,6 +646,14 @@ func (c *FeishuChannel) downloadResource(
|
||||||
})
|
})
|
||||||
return ""
|
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()
|
out.Close()
|
||||||
|
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
|
|
||||||
|
|
@ -663,7 +663,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
|
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -765,10 +765,15 @@ func (c *MatrixChannel) downloadMedia(
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err = io.Copy(tmp, reader)
|
// Limit media download to 50 MB to prevent disk exhaustion.
|
||||||
if err != nil {
|
const maxMediaSize int64 = 50 << 20 // 50 MB
|
||||||
|
written, err := io.CopyN(tmp, reader, maxMediaSize)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if written >= maxMediaSize {
|
||||||
|
return "", fmt.Errorf("matrix media exceeds %d MB size limit", maxMediaSize/(1<<20))
|
||||||
|
}
|
||||||
if err = readerClose(); err != nil {
|
if err = readerClose(); err != nil {
|
||||||
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
return "", fmt.Errorf("decrypt matrix media: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -793,7 +793,7 @@ func (c *WeComAIBotChannel) sendViaResponseURL(responseURL, content string) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading response_url body: %w: %w", channels.ErrTemporary, err)
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
const maxRespSize = 1 << 20 // 1 MB
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, readErr := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxRespSize))
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return "", channels.ClassifySendError(
|
return "", channels.ClassifySendError(
|
||||||
resp.StatusCode,
|
resp.StatusCode,
|
||||||
|
|
@ -379,8 +380,9 @@ func (c *WeComAppChannel) sendWeComMessage(ctx context.Context, accessToken stri
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
const maxSendRespSize = 1 << 20 // 1 MB
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
respBody, readErr := io.ReadAll(resp.Body)
|
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxSendRespSize))
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return channels.ClassifySendError(
|
return channels.ClassifySendError(
|
||||||
resp.StatusCode,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -550,13 +552,18 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read request body
|
// Read request body (limit to 4 MB to prevent memory exhaustion).
|
||||||
body, err := io.ReadAll(r.Body)
|
const maxBodySize = 4 << 20 // 4 MB
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
if len(body) > maxBodySize {
|
||||||
|
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Parse XML to get encrypted message
|
// Parse XML to get encrypted message
|
||||||
var encryptedMsg struct {
|
var encryptedMsg struct {
|
||||||
|
|
@ -697,7 +704,7 @@ func (c *WeComAppChannel) refreshAccessToken() error {
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -253,13 +253,18 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read request body
|
// Read request body (limit to 4 MB to prevent memory exhaustion).
|
||||||
body, err := io.ReadAll(r.Body)
|
const maxBodySize = 4 << 20 // 4 MB
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
if len(body) > maxBodySize {
|
||||||
|
http.Error(w, "Request body too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Parse XML to get encrypted message
|
// Parse XML to get encrypted message
|
||||||
var encryptedMsg struct {
|
var encryptedMsg struct {
|
||||||
|
|
@ -452,8 +457,9 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
const maxRespSize = 1 << 20 // 1 MB
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxRespSize))
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return channels.ClassifySendError(
|
return channels.ClassifySendError(
|
||||||
resp.StatusCode,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,17 @@ func SanitizeFilename(filename string) string {
|
||||||
return base
|
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
|
// DownloadOptions holds optional parameters for downloading files
|
||||||
type DownloadOptions struct {
|
type DownloadOptions struct {
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
ExtraHeaders map[string]string
|
ExtraHeaders map[string]string
|
||||||
LoggerPrefix string
|
LoggerPrefix string
|
||||||
ProxyURL string
|
ProxyURL string
|
||||||
|
MaxSize int64 // 0 = use MaxMediaDownloadSize default
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadFile downloads a file from URL to a local temp directory.
|
// 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()
|
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()
|
out.Close()
|
||||||
os.Remove(localPath)
|
os.Remove(localPath)
|
||||||
logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{
|
logger.ErrorCF(opts.LoggerPrefix, "Failed to write file", map[string]any{
|
||||||
|
|
@ -142,6 +152,14 @@ func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
|
||||||
})
|
})
|
||||||
return ""
|
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{
|
logger.DebugCF(opts.LoggerPrefix, "File downloaded successfully", map[string]any{
|
||||||
"path": localPath,
|
"path": localPath,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue