diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index 72b156a8..56faa876 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -80,6 +80,37 @@ var dangerousTags = []string{ "FilePermissions", // Writing this changes the file's permissions } +// controlOptions lists ExifTool command-line option names that collide with a +// tag assignment. A metadata key of "csv" becomes the argv entry "-csv=value", +// which exiftool reads as its own option rather than as a tag, so the value +// becomes a filename exiftool opens. Only an unprefixed key can collide: +// "-XMP:csv=value" is unambiguously a tag. +// +// See https://exiftool.org/exiftool_pod.html. +var controlOptions = []string{ + "api", "argfile", "charset", "common_args", "config", "csv", "diff", + "echo", "efile", "execute", "ext", "fileorder", "geotag", "geosync", + "htmldump", "if", "json", "lang", "listitem", "o", "out", "p", "php", + "require", "srcfile", "stay_open", "tagsfromfile", "textout", "use", "w", + "wm", "xmlformat", +} + +// isControlOption reports whether an unprefixed key would reach exiftool as +// one of its own options instead of as a tag assignment. +func isControlOption(key string) bool { + if strings.Contains(key, ":") { + return false + } + + for _, option := range controlOptions { + if strings.EqualFold(key, option) { + return true + } + } + + return false +} + // isDangerousTag reports whether key matches one of the [dangerousTags] // after case-insensitive comparison with any group prefix stripped. func isDangerousTag(key string) bool { @@ -114,6 +145,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) { if !safeKeyPattern.MatchString(key) { return nil, fmt.Errorf("write PDF metadata with ExifTool: invalid metadata key %q: %w", key, gotenberg.ErrPdfEngineMetadataValueNotSupported) } + if isControlOption(key) { + return nil, fmt.Errorf("write PDF metadata with ExifTool: metadata key %q is an ExifTool option, prefix it with a group such as %q: %w", key, "XMP:"+key, gotenberg.ErrPdfEngineMetadataValueNotSupported) + } tag := key if key == "Trapped" { diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go index 7cde8f12..5f64c019 100644 --- a/pkg/modules/exiftool/exiftool_test.go +++ b/pkg/modules/exiftool/exiftool_test.go @@ -2,6 +2,7 @@ package exiftool import ( "errors" + "fmt" "slices" "testing" @@ -211,3 +212,48 @@ func TestSafeKeyPattern(t *testing.T) { } } } + +// A metadata key that collides with an ExifTool option becomes a bare argv +// entry such as "-csv=/etc/passwd", which exiftool reads as its own option and +// treats the value as a filename to open. +func TestBuildExifToolWriteArgs_RejectsControlOptions(t *testing.T) { + for _, key := range []string{"csv", "CSV", "json", "geotag", "config", "tagsFromFile", "execute", "stay_open", "o", "w", "if", "p"} { + t.Run(key, func(t *testing.T) { + _, err := buildExifToolWriteArgs(map[string]any{key: "/etc/passwd"}) + if err == nil { + t.Fatalf("buildExifToolWriteArgs accepted the control option %q", key) + } + if !errors.Is(err, gotenberg.ErrPdfEngineMetadataValueNotSupported) { + t.Fatalf("error %v does not wrap ErrPdfEngineMetadataValueNotSupported", err) + } + }) + } +} + +// A group prefix makes the key unambiguous, so it must still be accepted. +func TestBuildExifToolWriteArgs_AcceptsPrefixedOptionNames(t *testing.T) { + for _, key := range []string{"XMP:csv", "XMP-dc:json", "IPTC:p"} { + t.Run(key, func(t *testing.T) { + args, err := buildExifToolWriteArgs(map[string]any{key: "value"}) + if err != nil { + t.Fatalf("buildExifToolWriteArgs rejected the prefixed key %q: %v", key, err) + } + want := fmt.Sprintf("-%s=value", key) + if len(args) != 1 || args[0] != want { + t.Fatalf("args = %v, want [%s]", args, want) + } + }) + } +} + +// Ordinary tags must be unaffected. +func TestBuildExifToolWriteArgs_AcceptsOrdinaryTags(t *testing.T) { + for _, key := range []string{"Author", "Title", "Subject", "Keywords", "Producer", "Creator"} { + t.Run(key, func(t *testing.T) { + _, err := buildExifToolWriteArgs(map[string]any{key: "value"}) + if err != nil { + t.Fatalf("buildExifToolWriteArgs rejected the ordinary tag %q: %v", key, err) + } + }) + } +}