소스 검색

fix(security): harden attachment downloads and avatar caching

- Replace os.Create with os.OpenFile 0600 perms at 3 file creation sites
- Add HTTPS scheme validation for avatar URL downloads (SSRF prevention)
- Add attachmentHTTPClient with 30s timeout (was bare http.Get)

Fixes: SEC #3, SEC #8, COMP #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 개월 전
부모
커밋
fefa53eb6c
2개의 변경된 파일16개의 추가작업 그리고 7개의 파일을 삭제
  1. 10 4
      internal/notifications/notifications.go
  2. 6 3
      internal/ui/chat/attachment_handler.go

+ 10 - 4
internal/notifications/notifications.go

@@ -5,6 +5,7 @@ import (
 	"io"
 	"log/slog"
 	"net/http"
+	neturl "net/url"
 	"os"
 	"path/filepath"
 	"time"
@@ -88,11 +89,10 @@ func getCachedProfileImage(avatarHash discord.Hash, url string) (string, error)
 		return path, nil
 	}
 
-	file, err := os.Create(path)
-	if err != nil {
-		return "", err
+	parsed, err := neturl.Parse(url)
+	if err != nil || parsed.Scheme != "https" {
+		return "", fmt.Errorf("refusing non-HTTPS avatar URL")
 	}
-	defer file.Close()
 
 	resp, err := avatarHTTPClient.Get(url)
 	if err != nil {
@@ -104,6 +104,12 @@ func getCachedProfileImage(avatarHash discord.Hash, url string) (string, error)
 		return "", fmt.Errorf("unexpected status %d fetching avatar", resp.StatusCode)
 	}
 
+	file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
+	if err != nil {
+		return "", err
+	}
+	defer file.Close()
+
 	if _, err := io.Copy(file, io.LimitReader(resp.Body, 10*1024*1024)); err != nil {
 		return "", err
 	}

+ 6 - 3
internal/ui/chat/attachment_handler.go

@@ -11,6 +11,7 @@ import (
 	"path/filepath"
 	"runtime"
 	"strings"
+	"time"
 
 	"github.com/ayn2op/discordo/internal/consts"
 	"github.com/ayn2op/discordo/internal/ui"
@@ -20,6 +21,8 @@ import (
 
 const maxAttachmentSize = 100 * 1024 * 1024 // 100 MB
 
+var attachmentHTTPClient = &http.Client{Timeout: 30 * time.Second}
+
 func openDefault(target string) error {
 	var cmd *exec.Cmd
 	switch runtime.GOOS {
@@ -49,7 +52,7 @@ func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string,
 		return "", fmt.Errorf("refusing non-HTTPS attachment URL: %s", parsed.Scheme)
 	}
 
-	resp, err := http.Get(attachment.URL)
+	resp, err := attachmentHTTPClient.Get(attachment.URL)
 	if err != nil {
 		return "", fmt.Errorf("failed to fetch attachment: %w", err)
 	}
@@ -69,7 +72,7 @@ func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string,
 		safeName = "attachment"
 	}
 	path := filepath.Join(dir, safeName)
-	file, err := os.Create(path)
+	file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
 	if err != nil {
 		return "", fmt.Errorf("failed to create attachment file: %w", err)
 	}
@@ -184,7 +187,7 @@ func (ml *messagesList) saveAttachmentImage(attachment discord.Attachment) {
 	}
 	defer src.Close()
 
-	dst, err := os.Create(destPath)
+	dst, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
 	if err != nil {
 		slog.Error("failed to create save file", "err", err, "path", destPath)
 		return