picoclaw/pkg/utils/media.go
Subash 934d855fd6 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
2026-03-14 14:31:25 +05:30

176 lines
4.6 KiB
Go

package utils
import (
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/logger"
)
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
func IsAudioFile(filename, contentType string) bool {
audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"}
for _, ext := range audioExtensions {
if strings.HasSuffix(strings.ToLower(filename), ext) {
return true
}
}
for _, audioType := range audioTypes {
if strings.HasPrefix(strings.ToLower(contentType), audioType) {
return true
}
}
return false
}
// SanitizeFilename removes potentially dangerous characters from a filename
// and returns a safe version for local filesystem storage.
func SanitizeFilename(filename string) string {
// Get the base filename without path
base := filepath.Base(filename)
// Remove any directory traversal attempts
base = strings.ReplaceAll(base, "..", "")
base = strings.ReplaceAll(base, "/", "_")
base = strings.ReplaceAll(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
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.
// Returns the local file path or empty string on error.
func DownloadFile(urlStr, filename string, opts DownloadOptions) string {
// Set defaults
if opts.Timeout == 0 {
opts.Timeout = 60 * time.Second
}
if opts.LoggerPrefix == "" {
opts.LoggerPrefix = "utils"
}
mediaDir := filepath.Join(os.TempDir(), "picoclaw_media")
if err := os.MkdirAll(mediaDir, 0o700); err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create media directory", map[string]any{
"error": err.Error(),
})
return ""
}
// Generate unique filename with UUID prefix to prevent conflicts
safeName := SanitizeFilename(filename)
localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName)
// Create HTTP request
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create download request", map[string]any{
"error": err.Error(),
})
return ""
}
// Add extra headers (e.g., Authorization for Slack)
for key, value := range opts.ExtraHeaders {
req.Header.Set(key, value)
}
client := &http.Client{Timeout: opts.Timeout}
if opts.ProxyURL != "" {
proxyURL, parseErr := url.Parse(opts.ProxyURL)
if parseErr != nil {
logger.ErrorCF(opts.LoggerPrefix, "Invalid proxy URL for download", map[string]any{
"error": parseErr.Error(),
"proxy": opts.ProxyURL,
})
return ""
}
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
}
resp, err := client.Do(req)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to download file", map[string]any{
"error": err.Error(),
"url": urlStr,
})
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.ErrorCF(opts.LoggerPrefix, "File download returned non-200 status", map[string]any{
"status": resp.StatusCode,
"url": urlStr,
})
return ""
}
out, err := os.Create(localPath)
if err != nil {
logger.ErrorCF(opts.LoggerPrefix, "Failed to create local file", map[string]any{
"error": err.Error(),
})
return ""
}
defer out.Close()
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{
"error": err.Error(),
})
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,
})
return localPath
}
// DownloadFileSimple is a simplified version of DownloadFile without options
func DownloadFileSimple(url, filename string) string {
return DownloadFile(url, filename, DownloadOptions{
LoggerPrefix: "media",
})
}