mirror of
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
synced 2026-09-28 13:14:08 +08:00
284 lines
9.3 KiB
Go
284 lines
9.3 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package errors
|
|
|
|
import (
|
|
stderrors "errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidResourceName = stderrors.New("invalid resource name")
|
|
ErrUnsafePath = stderrors.New("unsafe path detected")
|
|
getWorkingDir = os.Getwd
|
|
lstatPath = os.Lstat
|
|
evalSymlinks = filepath.EvalSymlinks
|
|
relPath = filepath.Rel
|
|
)
|
|
|
|
func ResourceName(name string) error {
|
|
if name == "" {
|
|
return stderrors.New("resource name cannot be empty")
|
|
}
|
|
if len([]rune(name)) > 128 {
|
|
return stderrors.New("resource name too long (max 128 characters)")
|
|
}
|
|
for _, r := range name {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' {
|
|
continue
|
|
}
|
|
return ErrInvalidResourceName
|
|
}
|
|
if unicode.IsDigit([]rune(name)[0]) {
|
|
return ErrInvalidResourceName
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
|
|
// These characters are invisible or alter text direction, allowing attackers to make
|
|
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
|
|
// (zero-width characters).
|
|
func isDangerousUnicode(r rune) bool {
|
|
switch {
|
|
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
|
|
return true
|
|
case r == 0xFEFF: // BOM / ZWNBSP
|
|
return true
|
|
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
|
|
return true
|
|
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
|
|
return true
|
|
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rejectControlChars rejects control characters in a string.
|
|
// Rejects C0 control characters (except \t and \n) and dangerous Unicode.
|
|
// Tab and newline are allowed as they may appear in legitimate multi-line input.
|
|
func rejectControlChars(s, fieldName string) error {
|
|
for _, r := range s {
|
|
// Allow tab (\t = 0x09) and newline (\n = 0x0A)
|
|
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
|
|
return fmt.Errorf("%s contains control characters", fieldName)
|
|
}
|
|
if isDangerousUnicode(r) {
|
|
return fmt.Errorf("%s contains dangerous Unicode characters", fieldName)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RejectControlChars rejects C0 control characters (except \t and \n) and
|
|
// dangerous Unicode characters from user input.
|
|
//
|
|
// Control characters cause subtle security issues:
|
|
// - Null bytes truncate strings at the C layer
|
|
// - \r\n enables HTTP header injection
|
|
// - Unicode Bidi characters allow visual spoofing (e.g. making "report.exe" display as "report.txt")
|
|
//
|
|
// Tab and newline are allowed as they may appear in legitimate multi-line input.
|
|
func RejectControlChars(value, flagName string) error {
|
|
return rejectControlChars(value, flagName)
|
|
}
|
|
|
|
// SafePath performs basic path validation checking for dangerous patterns.
|
|
// For full security (symlink resolution, CWD containment), use SafeOutputPath or SafeInputPath.
|
|
func SafePath(path string) error {
|
|
if path == "" {
|
|
return stderrors.New("path cannot be empty")
|
|
}
|
|
|
|
cleanPath := filepath.Clean(path)
|
|
if strings.Contains(cleanPath, "..") {
|
|
return ErrUnsafePath
|
|
}
|
|
if strings.ContainsRune(path, '\x00') {
|
|
return ErrUnsafePath
|
|
}
|
|
|
|
// Check for dangerous Unicode
|
|
for _, r := range path {
|
|
if isDangerousUnicode(r) {
|
|
return ErrUnsafePath
|
|
}
|
|
}
|
|
|
|
lowerPath := strings.ToLower(path)
|
|
for _, pattern := range []string{
|
|
"..",
|
|
"~",
|
|
"$(",
|
|
"`",
|
|
"|",
|
|
";",
|
|
"&",
|
|
"<",
|
|
">",
|
|
"\n",
|
|
"\r",
|
|
} {
|
|
if strings.Contains(lowerPath, pattern) {
|
|
return ErrUnsafePath
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SafeOutputPath validates a download/export target path for --output flags.
|
|
// It rejects absolute paths, resolves symlinks to their real location, and
|
|
// verifies the canonical result is still under the current working directory.
|
|
// This prevents an AI Agent from being tricked into writing files outside the
|
|
// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks
|
|
// to sensitive locations.
|
|
//
|
|
// The returned absolute path MUST be used for all subsequent I/O to prevent
|
|
// time-of-check-to-time-of-use (TOCTOU) race conditions.
|
|
func SafeOutputPath(path string) (string, error) {
|
|
return safePath(path, "--output")
|
|
}
|
|
|
|
// SafeInputPath validates an upload/read source path for --file flags.
|
|
// It applies the same rules as SafeOutputPath — rejecting absolute paths,
|
|
// resolving symlinks, and enforcing working directory containment — to prevent
|
|
// an AI Agent from being tricked into reading sensitive files like /etc/passwd.
|
|
func SafeInputPath(path string) (string, error) {
|
|
return safePath(path, "--file")
|
|
}
|
|
|
|
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
|
|
func safePath(raw, flagName string) (string, error) {
|
|
if err := rejectControlChars(raw, flagName); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
path := filepath.Clean(raw)
|
|
|
|
// Reject absolute paths - force relative paths within CWD
|
|
if filepath.IsAbs(path) {
|
|
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
|
|
}
|
|
|
|
cwd, err := getWorkingDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot determine working directory: %w", err)
|
|
}
|
|
resolved := filepath.Join(cwd, path)
|
|
|
|
// Resolve symlinks: for existing paths, follow to real location;
|
|
// for non-existing paths, walk up to the nearest existing ancestor,
|
|
// resolve its symlinks, and re-attach the remaining tail segments.
|
|
// This prevents TOCTOU attacks where a non-existent intermediate
|
|
// directory is replaced with a symlink between check and use.
|
|
if _, err := lstatPath(resolved); err == nil {
|
|
resolved, err = evalSymlinks(resolved)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
|
}
|
|
} else {
|
|
resolved, err = resolveNearestAncestor(resolved)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
|
|
}
|
|
}
|
|
|
|
canonicalCwd, _ := evalSymlinks(cwd)
|
|
if !isUnderDir(resolved, canonicalCwd) {
|
|
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
|
|
}
|
|
|
|
return resolved, nil
|
|
}
|
|
|
|
// resolveNearestAncestor walks up from path until it finds an existing
|
|
// ancestor, resolves that ancestor's symlinks, and re-joins the tail.
|
|
// This ensures even deeply nested non-existent paths are anchored to a
|
|
// real filesystem location, closing the TOCTOU symlink gap.
|
|
func resolveNearestAncestor(path string) (string, error) {
|
|
var tail []string
|
|
cur := path
|
|
for {
|
|
if _, err := lstatPath(cur); err == nil {
|
|
real, err := evalSymlinks(cur)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts := append([]string{real}, tail...)
|
|
return filepath.Join(parts...), nil
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
// Reached filesystem root without finding an existing ancestor;
|
|
// return path as-is and let the containment check reject it.
|
|
parts := append([]string{cur}, tail...)
|
|
return filepath.Join(parts...), nil
|
|
}
|
|
tail = append([]string{filepath.Base(cur)}, tail...)
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
// isUnderDir checks whether child is under parent directory.
|
|
func isUnderDir(child, parent string) bool {
|
|
rel, err := relPath(parent, child)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
|
|
}
|
|
|
|
// SafeLocalFlagPath validates a flag value as a local file path.
|
|
// Empty values and http/https URLs are returned unchanged without validation,
|
|
// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream.
|
|
// For all other values, SafeInputPath rules apply.
|
|
// The original relative path is returned unchanged (not resolved to absolute) so
|
|
// upload helpers can re-validate at the actual I/O point via SafeUploadPath.
|
|
func SafeLocalFlagPath(flagName, value string) (string, error) {
|
|
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
|
|
return value, nil
|
|
}
|
|
if _, err := SafeInputPath(value); err != nil {
|
|
return "", fmt.Errorf("%s: %v", flagName, err)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
|
|
// These characters enable MIME/HTTP header injection and must never appear in
|
|
// header field names, values, Content-ID, or filename parameters.
|
|
func RejectCRLF(value, fieldName string) error {
|
|
if strings.ContainsAny(value, "\r\n") {
|
|
return fmt.Errorf("%s contains invalid line break characters", fieldName)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StripQueryFragment removes any ?query or #fragment suffix from a URL path.
|
|
// API parameters must go through structured --params flags, not embedded in
|
|
// the path, to prevent parameter injection and behaviour confusion.
|
|
func StripQueryFragment(path string) string {
|
|
for i := 0; i < len(path); i++ {
|
|
if path[i] == '?' || path[i] == '#' {
|
|
return path[:i]
|
|
}
|
|
}
|
|
return path
|
|
}
|