summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-19 22:28:20 +0300
committerPaul Buetow <paul@buetow.org>2026-04-19 22:28:20 +0300
commita51ef0a5975cc6e1186c09d903d83608c159d30c (patch)
tree90f9184ed30286550e70baf03701dc38027379fa /internal
parent042b3e7d65c79e9bbaa17e901caca4c1dde4b0d7 (diff)
v4: harden image download symlink writes
Diffstat (limited to 'internal')
-rw-r--r--internal/image/download.go47
-rw-r--r--internal/image/download_test.go34
2 files changed, 81 insertions, 0 deletions
diff --git a/internal/image/download.go b/internal/image/download.go
index d66b946..6689a9c 100644
--- a/internal/image/download.go
+++ b/internal/image/download.go
@@ -61,6 +61,9 @@ func (d *Downloader) DownloadImage(ctx context.Context, result *SearchResult, ou
return fmt.Errorf("create output dir %q: %w", dir, err)
}
}
+ if err := ensureNoSymlinkAncestors(outputPath); err != nil {
+ return fmt.Errorf("validate output path %q: %w", outputPath, err)
+ }
if d.options != nil && !d.options.OverwriteExisting {
if _, err := os.Stat(outputPath); err == nil {
@@ -229,6 +232,50 @@ func joinWithinBaseDir(baseDir, name string) (string, error) {
return fullPath, nil
}
+func ensureNoSymlinkAncestors(path string) error {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return fmt.Errorf("resolve output path: %w", err)
+ }
+
+ absPath = filepath.Clean(absPath)
+ dir := filepath.Dir(absPath)
+ root := filepath.VolumeName(absPath) + string(os.PathSeparator)
+ if filepath.VolumeName(absPath) == "" {
+ root = string(os.PathSeparator)
+ }
+
+ for {
+ if dir == root || dir == "." || dir == string(os.PathSeparator) {
+ break
+ }
+
+ if info, err := os.Lstat(dir); err == nil {
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("path component %q is a symlink", dir)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("stat path component %q: %w", dir, err)
+ }
+
+ next := filepath.Dir(dir)
+ if next == dir {
+ break
+ }
+ dir = next
+ }
+
+ if info, err := os.Lstat(absPath); err == nil {
+ if info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("output path %q is a symlink", absPath)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("stat output path %q: %w", absPath, err)
+ }
+
+ return nil
+}
+
func sanitizeFileName(name string) string {
replacer := strings.NewReplacer(
"/", "_",
diff --git a/internal/image/download_test.go b/internal/image/download_test.go
index b9a6e31..84e8f5f 100644
--- a/internal/image/download_test.go
+++ b/internal/image/download_test.go
@@ -189,3 +189,37 @@ func TestDownloadImageRejectsNilResult(t *testing.T) {
t.Fatal("expected error for nil result")
}
}
+
+func TestDownloadImageRejectsSymlinkAncestor(t *testing.T) {
+ t.Parallel()
+
+ baseDir := t.TempDir()
+ outsideDir := t.TempDir()
+ linkDir := filepath.Join(baseDir, "link")
+ if err := os.Symlink(outsideDir, linkDir); err != nil {
+ t.Fatalf("Symlink() error = %v", err)
+ }
+
+ provider := &mockDownloaderProvider{payload: "image-bytes"}
+ d := NewDownloader(provider, &DownloadOptions{
+ OutputDir: baseDir,
+ CreateDir: true,
+ OverwriteExisting: true,
+ })
+
+ outputPath := filepath.Join(linkDir, "escape.png")
+ err := d.DownloadImage(context.Background(), &SearchResult{
+ URL: "https://example.com/image.png",
+ Source: Gemini,
+ Attribution: "safe attribution",
+ }, outputPath)
+ if err == nil {
+ t.Fatal("expected error for symlink ancestor")
+ }
+ if !strings.Contains(err.Error(), "symlink") {
+ t.Fatalf("DownloadImage() error = %v, want symlink rejection", err)
+ }
+ if _, statErr := os.Stat(filepath.Join(outsideDir, "escape.png")); !os.IsNotExist(statErr) {
+ t.Fatalf("unexpected file write outside output dir: %v", statErr)
+ }
+}