Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/validate/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 33 additions & 0 deletions internal/vfs/localfileio/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
34 changes: 30 additions & 4 deletions shortcuts/im/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"math"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -1309,12 +1310,37 @@ 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. 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)
}
return runtime.FileIO().Open(filePath)
}

// 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)
}
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
}
Expand Down Expand Up @@ -1347,11 +1373,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
}
Expand Down
4 changes: 2 additions & 2 deletions shortcuts/im/im_messages_reply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
13 changes: 11 additions & 2 deletions shortcuts/im/im_messages_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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"},
Expand Down Expand Up @@ -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)
}
Expand Down
Loading