From d3b46eca2ace41f3c1750194997373a0af6b8898 Mon Sep 17 00:00:00 2001 From: EvanYao826 <2869018789@qq.com> Date: Fri, 15 May 2026 16:24:20 +0800 Subject: [PATCH 1/2] fix(im): accept absolute paths for --image/--file flags The --image and --file flags on im +messages-send and +messages-reply rejected absolute paths with a validation error. This is inconvenient for AI agents and scripted callers that construct absolute paths. Changes: - Added SafeAbsoluteInputPath() to validate package for absolute paths that still checks control characters and resolves symlinks - Updated validateMediaFlagPath() to accept absolute paths after safety validation - Added openMediaFile/statMediaFile helpers that use os.Open/os.Stat for absolute paths and FileIO for relative paths - Updated help text to indicate absolute paths are accepted Fixes #872 --- internal/validate/path.go | 7 +++++++ internal/vfs/localfileio/path.go | 33 +++++++++++++++++++++++++++++++ shortcuts/im/helpers.go | 32 ++++++++++++++++++++++++++---- shortcuts/im/im_messages_reply.go | 4 ++-- shortcuts/im/im_messages_send.go | 13 ++++++++++-- 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/internal/validate/path.go b/internal/validate/path.go index 59d21c2c0..b8d71212f 100644 --- a/internal/validate/path.go +++ b/internal/validate/path.go @@ -28,3 +28,10 @@ func SafeEnvDirPath(path, envName string) (string, error) { func SafeLocalFlagPath(flagName, value string) (string, error) { return localfileio.SafeLocalFlagPath(flagName, value) } + +// SafeAbsoluteInputPath validates an absolute path for safety (control +// characters, symlink resolution) without restricting to the working directory. +func SafeAbsoluteInputPath(path string) error { + _, err := localfileio.SafeAbsoluteInputPath(path) + return err +} diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go index 7a4b52ab8..c4a8b25e9 100644 --- a/internal/vfs/localfileio/path.go +++ b/internal/vfs/localfileio/path.go @@ -24,10 +24,18 @@ func SafeInputPath(path string) (string, error) { // SafeLocalFlagPath validates a flag value as a local file path. // Empty values and http/https URLs are returned unchanged without validation. +// Absolute paths are accepted and validated for safety (control characters, +// symlink resolution) without restricting to the working directory. func SafeLocalFlagPath(flagName, value string) (string, error) { if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { return value, nil } + if filepath.IsAbs(value) { + if _, err := safePathAbsolute(value, flagName); err != nil { + return "", err + } + return value, nil + } if _, err := SafeInputPath(value); err != nil { return "", fmt.Errorf("%s: %v", flagName, err) } @@ -92,6 +100,31 @@ func safePath(raw, flagName string) (string, error) { return resolved, nil } +// safePathAbsolute validates an absolute path for safety without restricting +// to the working directory. It rejects control characters and resolves +// symlinks through the nearest existing ancestor. +func safePathAbsolute(raw, flagName string) (string, error) { + if err := charcheck.RejectControlChars(raw, flagName); err != nil { + return "", err + } + + path := filepath.Clean(raw) + + resolved, err := resolveNearestAncestor(path) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + return resolved, nil +} + +// SafeAbsoluteInputPath validates an absolute path for safety (control +// characters, symlink resolution) without restricting to the working directory. +// This is intended for user-provided flag values where absolute paths are +// explicitly allowed. +func SafeAbsoluteInputPath(path string) (string, error) { + return safePathAbsolute(path, "path") +} + func resolveNearestAncestor(path string) (string, error) { var tail []string cur := path diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index 5b42c32b7..d5d66b73b 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -13,6 +13,7 @@ import ( "math" "net/http" "net/url" + "os" "path" "path/filepath" "regexp" @@ -1309,12 +1310,35 @@ func detectIMFileType(filePath string) string { const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files +// openMediaFile opens a file for media upload. For absolute paths, it uses +// os.Open directly (the path must have been validated by validateMediaFlagPath). +// For relative paths, it uses the FileIO provider which restricts to cwd. +func openMediaFile(runtime *common.RuntimeContext, filePath string) (io.ReadCloser, error) { + if filepath.IsAbs(filePath) { + return os.Open(filePath) + } + return runtime.FileIO().Open(filePath) +} + +// statMediaFile returns file info for a media file. For absolute paths, it uses +// os.Stat directly. For relative paths, it uses the FileIO provider. +func statMediaFile(runtime *common.RuntimeContext, filePath string) (os.FileInfo, error) { + if filepath.IsAbs(filePath) { + return os.Stat(filePath) + } + fi, err := runtime.FileIO().Stat(filePath) + if err != nil { + return nil, err + } + return fi.(os.FileInfo), nil +} + func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, imageType string) (string, error) { - if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxImageUploadSize { + if info, err := statMediaFile(runtime, filePath); err == nil && info.Size() > maxImageUploadSize { return "", fmt.Errorf("image size %s exceeds limit (max 5MB)", common.FormatSize(info.Size())) } - f, err := runtime.FileIO().Open(filePath) + f, err := openMediaFile(runtime, filePath) if err != nil { return "", err } @@ -1347,11 +1371,11 @@ func uploadImageToIM(ctx context.Context, runtime *common.RuntimeContext, filePa } func uploadFileToIM(ctx context.Context, runtime *common.RuntimeContext, filePath, fileType, duration string) (string, error) { - if info, err := runtime.FileIO().Stat(filePath); err == nil && info.Size() > maxFileUploadSize { + if info, err := statMediaFile(runtime, filePath); err == nil && info.Size() > maxFileUploadSize { return "", fmt.Errorf("file size %s exceeds limit (max 100MB)", common.FormatSize(info.Size())) } - f, err := runtime.FileIO().Open(filePath) + f, err := openMediaFile(runtime, filePath) if err != nil { return "", err } diff --git a/shortcuts/im/im_messages_reply.go b/shortcuts/im/im_messages_reply.go index 806ee739c..8e78109f3 100644 --- a/shortcuts/im/im_messages_reply.go +++ b/shortcuts/im/im_messages_reply.go @@ -29,8 +29,8 @@ var ImMessagesReply = common.Shortcut{ {Name: "content", Desc: "(one of --content/--text/--markdown/--image/--file/--video/--audio required) message content JSON"}, {Name: "text", Desc: "plain text message (auto-wrapped as JSON)"}, {Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"}, - {Name: "image", Desc: "image_key, local file path"}, - {Name: "file", Desc: "file_key, local file path"}, + {Name: "image", Desc: "image_key or local file path (absolute or relative)"}, + {Name: "file", Desc: "file_key or local file path (absolute or relative)"}, {Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"}, {Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"}, {Name: "audio", Desc: "audio file_key, local file path"}, diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index efaa54852..d7c213dba 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -8,10 +8,12 @@ import ( "encoding/json" "net/http" "os" + "path/filepath" "strings" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) @@ -33,8 +35,8 @@ var ImMessagesSend = common.Shortcut{ {Name: "text", Desc: "plain text message (auto-wrapped as JSON)"}, {Name: "markdown", Desc: "markdown text (auto-wrapped as post format with style optimization; image URLs auto-resolved)"}, {Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"}, - {Name: "image", Desc: "image_key, local file path"}, - {Name: "file", Desc: "file_key, local file path"}, + {Name: "image", Desc: "image_key or local file path (absolute or relative)"}, + {Name: "file", Desc: "file_key or local file path (absolute or relative)"}, {Name: "video", Desc: "video file_key, local file path; must be used together with --video-cover"}, {Name: "video-cover", Desc: "video cover image_key, local file path; required when using --video"}, {Name: "audio", Desc: "audio file_key, local file path"}, @@ -215,6 +217,13 @@ func validateMediaFlagPath(fio fileio.FileIO, flagName, value string) error { if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") || isMediaKey(value) { return nil } + // For absolute paths, validate safety without restricting to cwd. + if filepath.IsAbs(value) { + if err := validate.SafeAbsoluteInputPath(value); err != nil { + return output.ErrValidation("%s: %v", flagName, err) + } + return nil + } if _, err := fio.Stat(value); err != nil && !os.IsNotExist(err) { return output.ErrValidation("%s: %v", flagName, err) } From 82de8b643fe0770233b2e2491a710eadfe1048b7 Mon Sep 17 00:00:00 2001 From: EvanYao826 <2869018789@qq.com> Date: Fri, 15 May 2026 16:46:13 +0800 Subject: [PATCH 2/2] docs: clarify caller validation contract for openMediaFile/statMediaFile Address CodeRabbit review: document that callers must validate paths before calling these helpers, since they use os.Open/os.Stat directly for absolute paths without additional safety checks. --- shortcuts/im/helpers.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shortcuts/im/helpers.go b/shortcuts/im/helpers.go index d5d66b73b..532458af2 100644 --- a/shortcuts/im/helpers.go +++ b/shortcuts/im/helpers.go @@ -1310,9 +1310,10 @@ func detectIMFileType(filePath string) string { const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files -// openMediaFile opens a file for media upload. For absolute paths, it uses -// os.Open directly (the path must have been validated by validateMediaFlagPath). -// For relative paths, it uses the FileIO provider which restricts to cwd. +// openMediaFile opens a file for media upload. The caller MUST validate the +// path first (e.g. via validateMediaFlagPath). For absolute paths, it uses +// os.Open directly. For relative paths, it uses the FileIO provider which +// restricts to the working directory. func openMediaFile(runtime *common.RuntimeContext, filePath string) (io.ReadCloser, error) { if filepath.IsAbs(filePath) { return os.Open(filePath) @@ -1320,8 +1321,9 @@ func openMediaFile(runtime *common.RuntimeContext, filePath string) (io.ReadClos return runtime.FileIO().Open(filePath) } -// statMediaFile returns file info for a media file. For absolute paths, it uses -// os.Stat directly. For relative paths, it uses the FileIO provider. +// statMediaFile returns file info for a media file. The caller MUST validate +// the path first (e.g. via validateMediaFlagPath). For absolute paths, it +// uses os.Stat directly. For relative paths, it uses the FileIO provider. func statMediaFile(runtime *common.RuntimeContext, filePath string) (os.FileInfo, error) { if filepath.IsAbs(filePath) { return os.Stat(filePath)