diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-19 22:28:20 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-19 22:28:20 +0300 |
| commit | a51ef0a5975cc6e1186c09d903d83608c159d30c (patch) | |
| tree | 90f9184ed30286550e70baf03701dc38027379fa /internal | |
| parent | 042b3e7d65c79e9bbaa17e901caca4c1dde4b0d7 (diff) | |
v4: harden image download symlink writes
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/image/download.go | 47 | ||||
| -rw-r--r-- | internal/image/download_test.go | 34 |
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) + } +} |
