diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-27 08:33:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-27 08:33:44 +0300 |
| commit | 257057748a2cf2070fbeb722a94772857b5d014f (patch) | |
| tree | cd3110920d12d2b22ba4918898aa4437799d1bba | |
| parent | 8b37e6e04035f9f8a3d01701dae121cdc67cbf43 (diff) | |
processor: reject markdown image refs with path separators or parent traversal
findLocalImages used filepath.Base(ref) after stat succeeded, which
caused subdirectory or parent-directory references to pass the scan but
then fail during copy because the basename was looked up in the wrong
directory.
Fix: add isSimpleImageRef helper that accepts only flat filenames (no
path separators, no .. traversal). findLocalImages now returns the ref
unchanged, matching what copyLocalImages and claimedByMarkdown expect.
Added tests for isSimpleImageRef and negative findLocalImages cases for
subdirectory and parent-directory references.
| -rw-r--r-- | internal/processor/markdown.go | 23 | ||||
| -rw-r--r-- | internal/processor/markdown_test.go | 60 |
2 files changed, 82 insertions, 1 deletions
diff --git a/internal/processor/markdown.go b/internal/processor/markdown.go index e09cf59..c258e4c 100644 --- a/internal/processor/markdown.go +++ b/internal/processor/markdown.go @@ -13,6 +13,18 @@ import ( "github.com/yuin/goldmark/renderer/html" ) +// isSimpleImageRef returns true for a filename-only reference (e.g. +// "img.png") that is safe to treat as a flat local file in the same +// directory as the markdown source. It rejects subdirectories, absolute +// paths, dot-slash prefixes, and parent-directory traversal so stat and +// copy targets stay within the source directory. +func isSimpleImageRef(ref string) bool { + if strings.Contains(ref, "..") { + return false + } + return filepath.Base(ref) == ref +} + // imageRefPattern matches Markdown image syntax:  // We use it to discover local asset references that must be copied. var imageRefPattern = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`) @@ -62,9 +74,18 @@ func findLocalImages(mdContent, sourceDir string) []string { continue } + // Reject references that traverse directories or contain path + // separators; only flat filenames next to the markdown are + // supported. This prevents scans from succeeding on a file + // deep in a subdirectory and then failing copy because the + // basename is looked up in the wrong directory. + if !isSimpleImageRef(ref) { + continue + } + candidate := filepath.Join(sourceDir, ref) if _, err := os.Stat(candidate); err == nil { - locals = append(locals, filepath.Base(ref)) + locals = append(locals, ref) } } diff --git a/internal/processor/markdown_test.go b/internal/processor/markdown_test.go index 2445b53..c53874d 100644 --- a/internal/processor/markdown_test.go +++ b/internal/processor/markdown_test.go @@ -9,6 +9,33 @@ import ( "codeberg.org/snonux/snonux/internal/config" ) +func TestIsSimpleImageRef(t *testing.T) { + t.Parallel() + + cases := []struct { + ref string + want bool + }{ + {"img.png", true}, + {"a.png", true}, + {"sub/img.png", false}, + {"a/b/c.png", false}, + {"../img.png", false}, + {"img/../other.png", false}, + {"/etc/passwd", false}, + } + + for _, c := range cases { + t.Run(c.ref, func(t *testing.T) { + t.Parallel() + got := isSimpleImageRef(c.ref) + if got != c.want { + t.Fatalf("isSimpleImageRef(%q) = %v; want %v", c.ref, got, c.want) + } + }) + } +} + func TestFindLocalImages(t *testing.T) { t.Parallel() @@ -42,6 +69,38 @@ func TestFindLocalImages(t *testing.T) { } }) + t.Run("subdir ref ignored even if file exists", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "photo.png"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + got := findLocalImages(``, dir) + if len(got) != 0 { + t.Fatalf("expected no locals for subdir ref, got %v", got) + } + }) + + t.Run("parent traversal ref ignored even if file exists", func(t *testing.T) { + t.Parallel() + // Create a layout where ../photo.png from dir would resolve to a real file. + base := t.TempDir() + dir := filepath.Join(base, "inbox") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(base, "photo.png"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + got := findLocalImages(``, dir) + if len(got) != 0 { + t.Fatalf("expected no locals for traversal ref, got %v", got) + } + }) + tests := []struct { name string md string @@ -62,6 +121,7 @@ func TestFindLocalImages(t *testing.T) { want: []string{"z.gif"}, wantLen: 1, }, + } for _, tt := range tests { |
