chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"io"
"os"
"github.com/larksuite/cli/internal/vfs/localfileio"
)
// AtomicWrite writes data to path atomically.
// Delegates to localfileio.AtomicWrite.
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
return localfileio.AtomicWrite(path, data, perm)
}
// AtomicWriteFromReader atomically copies reader contents into path.
// Delegates to localfileio.AtomicWriteFromReader.
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
return localfileio.AtomicWriteFromReader(path, reader, perm)
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"os"
"path/filepath"
"runtime"
"sync"
"testing"
)
func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) {
// GIVEN: a target path in a temp directory
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
data := []byte(`{"key":"value"}`)
// WHEN: AtomicWrite writes data with 0644 permission
if err := AtomicWrite(path, data, 0644); err != nil {
t.Fatalf("AtomicWrite failed: %v", err)
}
// THEN: file content matches exactly
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if string(got) != string(data) {
t.Errorf("content = %q, want %q", got, data)
}
}
func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission test not reliable on Windows")
}
// GIVEN: a target path
dir := t.TempDir()
path := filepath.Join(dir, "secret.json")
// WHEN: AtomicWrite writes with 0600 permission
if err := AtomicWrite(path, []byte("secret"), 0600); err != nil {
t.Fatalf("AtomicWrite failed: %v", err)
}
// THEN: file permission is exactly 0600 (owner read-write only)
info, _ := os.Stat(path)
if perm := info.Mode().Perm(); perm != 0600 {
t.Errorf("permission = %04o, want 0600", perm)
}
}
func TestAtomicWrite_OverwritesExistingFile(t *testing.T) {
// GIVEN: an existing file with old content
dir := t.TempDir()
path := filepath.Join(dir, "test.json")
AtomicWrite(path, []byte("old"), 0644)
// WHEN: AtomicWrite overwrites with new content
if err := AtomicWrite(path, []byte("new"), 0644); err != nil {
t.Fatalf("second write failed: %v", err)
}
// THEN: file contains new content
got, _ := os.ReadFile(path)
if string(got) != "new" {
t.Errorf("content = %q, want %q", got, "new")
}
}
func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) {
// GIVEN: a target path in a non-existent nested directory
path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt")
// WHEN: AtomicWrite fails (parent directory doesn't exist)
err := AtomicWrite(path, []byte("data"), 0644)
// THEN: the write fails
if err == nil {
t.Fatal("expected error writing to nonexistent dir")
}
// THEN: no .tmp files are left behind
parentDir := filepath.Dir(filepath.Dir(path))
entries, _ := os.ReadDir(parentDir)
for _, e := range entries {
if filepath.Ext(e.Name()) == ".tmp" {
t.Errorf("residual temp file found: %s", e.Name())
}
}
}
func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) {
// GIVEN: an existing file with known content
dir := t.TempDir()
original := []byte("original content")
path := filepath.Join(dir, "file.json")
if err := AtomicWrite(path, original, 0644); err != nil {
t.Fatal(err)
}
// WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root)
badPath := filepath.Join(dir, "no", "such", "dir", "file.json")
err := AtomicWrite(badPath, []byte("new"), 0644)
// THEN: write fails
if err == nil {
t.Fatal("expected error writing to non-existent dir")
}
// THEN: the original file at the valid path is untouched
got, _ := os.ReadFile(path)
if string(got) != string(original) {
t.Errorf("original file corrupted: got %q, want %q", got, original)
}
}
func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) {
// GIVEN: a target file that will be written by 20 concurrent goroutines
dir := t.TempDir()
path := filepath.Join(dir, "concurrent.json")
// WHEN: 20 goroutines write simultaneously
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(n int) {
defer wg.Done()
data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`)
AtomicWrite(path, data, 0644)
}(i)
}
wg.Wait()
// THEN: file exists and is valid (not corrupted by interleaved writes)
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if len(got) == 0 {
t.Error("file is empty after concurrent writes")
}
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters from user input.
//
// Delegates to charcheck.RejectControlChars — the single source of truth
// for character-level security checks.
func RejectControlChars(value, flagName string) error {
return charcheck.RejectControlChars(value, flagName)
}
// 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
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"testing"
)
func TestRejectControlChars_FiltersControlCharsAndDangerousUnicode(t *testing.T) {
for _, tt := range []struct {
name string
input string
wantErr bool
}{
// ── GIVEN: normal text → THEN: allowed ──
{"plain text", "hello world", false},
{"with tab", "hello\tworld", false},
{"with newline", "hello\nworld", false},
{"unicode text", "你好世界", false},
{"with symbols", "hello!@#$^&*()", false},
{"empty", "", false},
// ── GIVEN: C0 control characters → THEN: rejected ──
{"null byte", "hello\x00world", true},
{"bell", "hello\x07world", true},
{"backspace", "hello\x08world", true},
{"escape", "hello\x1bworld", true},
{"carriage return", "hello\rworld", true},
{"form feed", "hello\x0cworld", true},
{"vertical tab", "hello\x0bworld", true},
{"DEL", "hello\x7fworld", true},
// ── GIVEN: dangerous Unicode characters → THEN: rejected ──
{"zero width space", "hello\u200Bworld", true},
{"zero width non-joiner", "hello\u200Cworld", true},
{"zero width joiner", "hello\u200Dworld", true},
{"BOM", "hello\uFEFFworld", true},
{"bidi LRE", "hello\u202Aworld", true},
{"bidi RLE", "hello\u202Bworld", true},
{"bidi PDF", "hello\u202Cworld", true},
{"bidi LRO", "hello\u202Dworld", true},
{"bidi RLO", "hello\u202Eworld", true},
{"line separator", "hello\u2028world", true},
{"paragraph separator", "hello\u2029world", true},
{"bidi LRI", "hello\u2066world", true},
{"bidi RLI", "hello\u2067world", true},
{"bidi FSI", "hello\u2068world", true},
{"bidi PDI", "hello\u2069world", true},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN: RejectControlChars validates the input
err := RejectControlChars(tt.input, "--test")
// THEN: error matches expectation
if (err != nil) != tt.wantErr {
t.Errorf("RejectControlChars(%q) error = %v, wantErr %v",
tt.input, err, tt.wantErr)
}
})
}
}
func TestStripQueryFragment(t *testing.T) {
for _, tt := range []struct {
name string
in string
want string
}{
{"no query or fragment", "/open-apis/test", "/open-apis/test"},
{"query only", "/open-apis/test?admin=true", "/open-apis/test"},
{"fragment only", "/open-apis/test#section", "/open-apis/test"},
{"query and fragment", "/open-apis/test?a=1#frag", "/open-apis/test"},
{"empty string", "", ""},
{"query at start", "?foo=bar", ""},
{"fragment at start", "#frag", ""},
} {
t.Run(tt.name, func(t *testing.T) {
got := StripQueryFragment(tt.in)
if got != tt.want {
t.Errorf("StripQueryFragment(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import "github.com/larksuite/cli/internal/vfs/localfileio"
// SafeOutputPath validates a download/export target path.
// Delegates to localfileio.SafeOutputPath.
func SafeOutputPath(path string) (string, error) {
return localfileio.SafeOutputPath(path)
}
// SafeInputPath validates an upload/read source path.
// Delegates to localfileio.SafeInputPath.
func SafeInputPath(path string) (string, error) {
return localfileio.SafeInputPath(path)
}
// SafeEnvDirPath validates an environment-provided application directory path.
// Delegates to localfileio.SafeEnvDirPath.
func SafeEnvDirPath(path, envName string) (string, error) {
return localfileio.SafeEnvDirPath(path, envName)
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Delegates to localfileio.SafeLocalFlagPath.
func SafeLocalFlagPath(flagName, value string) (string, error) {
return localfileio.SafeLocalFlagPath(flagName, value)
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) {
for _, tt := range []struct {
name string
input string
wantErr bool
}{
// ── GIVEN: normal relative paths → THEN: allowed ──
{"normal file", "report.xlsx", false},
{"subdir file", "output/report.xlsx", false},
{"current dir explicit", "./file.txt", false},
{"nested subdir", "a/b/c/file.txt", false},
{"dot in name", "my.report.v2.xlsx", false},
{"space in name", "my file.txt", false},
{"unicode normal", "报告.xlsx", false},
{"dot-dot resolves to cwd", "subdir/..", false},
// ── GIVEN: empty or blank paths → THEN: rejected ──
{"empty path", "", true},
{"blank path", " ", true},
// ── GIVEN: path traversal via .. → THEN: rejected ──
{"dot-dot escape", "../../.ssh/authorized_keys", true},
{"dot-dot mid path", "subdir/../../etc/passwd", true},
{"triple dot-dot", "../../../etc/shadow", true},
// ── GIVEN: absolute paths → THEN: rejected ──
{"absolute path unix", "/etc/passwd", true},
{"absolute path root", "/tmp/evil", true},
// ── GIVEN: control characters in path → THEN: rejected ──
{"null byte", "file\x00.txt", true},
{"carriage return", "file\r.txt", true},
{"bell char", "file\x07.txt", true},
// ── GIVEN: dangerous Unicode in path → THEN: rejected ──
{"bidi RLO", "file\u202Ename.txt", true},
{"zero width space", "file\u200Bname.txt", true},
{"BOM char", "file\uFEFFname.txt", true},
{"line separator", "file\u2028name.txt", true},
{"bidi LRI", "file\u2066name.txt", true},
// ── GIVEN: looks dangerous but is actually safe → THEN: allowed ──
{"literal percent 2e", "%2e%2e/etc/passwd", false},
{"tilde path", "~/file.txt", false},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN: SafeOutputPath validates the path
_, err := SafeOutputPath(tt.input)
// THEN: error matches expectation
if (err != nil) != tt.wantErr {
t.Errorf("SafeOutputPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) {
// GIVEN: a clean temp directory as CWD
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
// WHEN: SafeOutputPath validates a relative path
got, err := SafeOutputPath("output/file.txt")
// THEN: returns the canonical absolute path for subsequent I/O
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(dir, "output", "file.txt")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSafeOutputPath_RejectsSymlinkEscapingCWD(t *testing.T) {
// GIVEN: a symlink in CWD pointing to /etc (outside CWD)
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
os.Symlink("/etc", filepath.Join(dir, "link-to-etc"))
// WHEN: SafeOutputPath validates a path through the symlink
_, err := SafeOutputPath("link-to-etc/passwd")
// THEN: rejected because the resolved path is outside CWD
if err == nil {
t.Error("expected error for symlink escaping CWD, got nil")
}
}
func TestSafeOutputPath_AllowsSymlinkWithinCWD(t *testing.T) {
// GIVEN: a symlink in CWD pointing to a subdirectory within CWD
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
os.MkdirAll(filepath.Join(dir, "real"), 0755)
os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "link"))
// WHEN: SafeOutputPath validates a path through the internal symlink
got, err := SafeOutputPath("link/file.txt")
// THEN: allowed, resolved to the real path within CWD
if err != nil {
t.Fatalf("symlink within CWD should be allowed: %v", err)
}
want := filepath.Join(dir, "real", "file.txt")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSafeOutputPath_ResolvesAncestorSymlinkWhenParentMissing(t *testing.T) {
// GIVEN: CWD contains a symlink "escape" → /etc, and the target path
// goes through "escape/sub/file.txt" where "sub" does not exist.
// The old code failed to resolve the symlink because the immediate
// parent ("escape/sub") didn't exist, leaving resolved un-anchored.
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
os.Symlink("/etc", filepath.Join(dir, "escape"))
// WHEN: SafeOutputPath validates a path through the symlink with missing intermediate dirs
_, err := SafeOutputPath("escape/nonexistent/file.txt")
// THEN: rejected — the resolved path is under /etc, outside CWD
if err == nil {
t.Error("expected error for symlink escaping CWD via non-existent parent, got nil")
}
}
func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
// GIVEN: a deeply nested non-existent path with no symlinks
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(dir)
// WHEN: SafeOutputPath validates "a/b/c/d/file.txt" (none of a/b/c/d exist)
got, err := SafeOutputPath("a/b/c/d/file.txt")
// THEN: allowed, resolved to canonical path under CWD
if err != nil {
t.Fatalf("deep non-existent path within CWD should be allowed: %v", err)
}
want := filepath.Join(dir, "a", "b", "c", "d", "file.txt")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSafeLocalFlagPath(t *testing.T) {
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
orig, _ := os.Getwd()
defer os.Chdir(orig)
os.Chdir(dir)
os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0600)
for _, tt := range []struct {
name string
flag string
value string
want string
wantErr string
}{
{"empty value passes through", "--image", "", "", ""},
{"http URL passes through", "--image", "http://example.com/a.jpg", "http://example.com/a.jpg", ""},
{"https URL passes through", "--image", "https://example.com/a.jpg", "https://example.com/a.jpg", ""},
{"relative path accepted, returned unchanged", "--file", "photo.jpg", "photo.jpg", ""},
{"path traversal rejected", "--file", "../escape.txt", "", "--file"},
{"absolute path rejected", "--image", "/etc/passwd", "", "--image"},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := SafeLocalFlagPath(tt.flag, tt.value)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("SafeLocalFlagPath(%q, %q) error = %v, want contains %q", tt.flag, tt.value, err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("SafeLocalFlagPath(%q, %q) unexpected error: %v", tt.flag, tt.value, err)
}
if got != tt.want {
t.Fatalf("SafeLocalFlagPath(%q, %q) = %q, want %q", tt.flag, tt.value, got, tt.want)
}
})
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
t.Fatalf("CreateTemp: %v", err)
}
tmpPath := f.Name()
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
// WHEN: SafeUploadPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// THEN: absolute paths are rejected even in temp dir
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
}
}
func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) {
// GIVEN: an absolute path outside the temp directory
// WHEN / THEN: SafeUploadPath rejects it
_, err := SafeInputPath("/etc/passwd")
if err == nil {
t.Error("expected error for absolute non-temp path, got nil")
}
}
func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) {
// GIVEN: a clean temp CWD with a real file
dir := t.TempDir()
dir, _ = filepath.EvalSymlinks(dir)
orig, _ := os.Getwd()
defer os.Chdir(orig)
os.Chdir(dir)
os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600)
// WHEN: SafeUploadPath validates a relative path to an existing file
got, err := SafeInputPath("upload.bin")
// THEN: accepted and returned as absolute canonical path
if err != nil {
t.Fatalf("SafeUploadPath(relative) error = %v", err)
}
want := filepath.Join(dir, "upload.bin")
if got != want {
t.Errorf("SafeUploadPath(relative) = %q, want %q", got, want)
}
}
func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) {
// GIVEN: an absolute path
// WHEN: SafeInputPath rejects it
_, err := SafeInputPath("/etc/passwd")
// THEN: error message mentions --file (not --output)
if err == nil {
t.Fatal("expected error for absolute path")
}
if !strings.Contains(err.Error(), "--file") {
t.Errorf("error should mention --file, got: %s", err.Error())
}
// WHEN: SafeOutputPath rejects it
_, err = SafeOutputPath("/etc/passwd")
// THEN: error message mentions --output (not --file)
if err == nil {
t.Fatal("expected error for absolute path")
}
if !strings.Contains(err.Error(), "--output") {
t.Errorf("error should mention --output, got: %s", err.Error())
}
}
// TestSafeEnvDirPath_RequiresAbsolutePath verifies that environment-provided
// directory paths must be absolute.
func TestSafeEnvDirPath_RequiresAbsolutePath(t *testing.T) {
_, err := SafeEnvDirPath("logs", "LARKSUITE_CLI_LOG_DIR")
if err == nil {
t.Fatal("expected error for relative path")
}
if !strings.Contains(err.Error(), "LARKSUITE_CLI_LOG_DIR") {
t.Fatalf("error should mention env name, got %v", err)
}
}
// TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath verifies that a valid
// absolute environment directory is cleaned and resolved to its canonical path.
func TestSafeEnvDirPath_ReturnsNormalizedAbsolutePath(t *testing.T) {
base := t.TempDir()
base, _ = filepath.EvalSymlinks(base)
got, err := SafeEnvDirPath(filepath.Join(base, "logs", "..", "auth"), "LARKSUITE_CLI_LOG_DIR")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := filepath.Join(base, "auth")
if got != want {
t.Fatalf("SafeEnvDirPath() = %q, want %q", got, want)
}
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// unsafeResourceChars matches URL-special characters, control characters,
// and percent signs (to prevent %2e%2e encoding bypass).
var unsafeResourceChars = regexp.MustCompile(`[?#%\x00-\x1f\x7f]`)
// ResourceName validates an API resource identifier (messageId, fileToken, etc.)
// before it is interpolated into a URL path via fmt.Sprintf. It rejects path
// traversal (..), URL metacharacters (?#%), percent-encoded bypasses (%2e%2e),
// control characters, and dangerous Unicode.
//
// Without this check, an input like "../admin" or "?evil=true" in a message ID
// would alter the API endpoint the request is sent to. Works alongside
// EncodePathSegment for defense-in-depth.
func ResourceName(name, flagName string) error {
if name == "" {
return fmt.Errorf("%s must not be empty", flagName)
}
for _, seg := range strings.Split(name, "/") {
if seg == ".." {
return fmt.Errorf("%s must not contain '..' path traversal", flagName)
}
}
if unsafeResourceChars.MatchString(name) {
return fmt.Errorf("%s contains invalid characters", flagName)
}
for _, r := range name {
if charcheck.IsDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", flagName)
}
}
return nil
}
// EncodePathSegment percent-encodes user input for safe use as a single URL path
// segment (e.g. / → %2F, ? → %3F, # → %23), ensuring the value cannot alter the
// URL routing structure when interpolated into an API path.
//
// This provides defense-in-depth alongside ResourceName: ResourceName rejects known
// dangerous patterns at the input layer, while EncodePathSegment acts as a fallback
// at the concatenation layer — if ResourceName rules are relaxed in the future, or
// if an API path bypasses ResourceName validation (e.g. cmd/service/ generic calls),
// encoding still prevents special characters from being interpreted as path separators
// or query parameters.
//
// Convention: all user-provided variables in fmt.Sprintf API paths within shortcuts/
// MUST be wrapped with this function.
func EncodePathSegment(s string) string {
return url.PathEscape(s)
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"strings"
"testing"
)
func TestResourceName_RejectsInjectionPatterns(t *testing.T) {
for _, tt := range []struct {
name string
input string
flag string
wantErr bool
}{
// ── GIVEN: normal API identifiers → THEN: allowed ──
{"normal id", "om_abc123", "--message", false},
{"file token", "boxcnXYZ789", "--file-token", false},
{"with slash", "files/abc", "--resource", false},
{"with underscore", "om_xxx_yyy", "--message", false},
{"with hyphen", "file-token-123", "--file-token", false},
{"single char", "a", "--id", false},
{"slash only", "/", "--id", false},
// ── GIVEN: path traversal attempts → THEN: rejected ──
{"dot-dot traversal", "../admin/secret", "--message", true},
{"mid path traversal", "files/../admin", "--message", true},
{"bare dot-dot", "..", "--message", true},
// ── GIVEN: URL special characters → THEN: rejected ──
{"question mark", "id?admin=true", "--id", true},
{"hash fragment", "id#section", "--id", true},
{"percent encoding", "id%2e%2e", "--id", true},
// ── GIVEN: control characters → THEN: rejected ──
{"null byte", "id\x00rest", "--id", true},
{"newline", "id\nrest", "--id", true},
{"tab", "id\trest", "--id", true},
{"escape char", "id\x1brest", "--id", true},
// ── GIVEN: dangerous Unicode → THEN: rejected ──
{"bidi RLO", "om_\u202Exxx", "--message", true},
{"zero width space", "om_\u200Bxxx", "--message", true},
{"BOM", "om_\uFEFFxxx", "--message", true},
// ── GIVEN: empty input → THEN: rejected ──
{"empty string", "", "--message", true},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN: ResourceName validates the identifier
err := ResourceName(tt.input, tt.flag)
// THEN: error matches expectation
if (err != nil) != tt.wantErr {
t.Errorf("ResourceName(%q, %q) error = %v, wantErr %v",
tt.input, tt.flag, err, tt.wantErr)
}
})
}
}
func TestResourceName_ErrorMessageContainsFlagName(t *testing.T) {
// GIVEN: an empty resource name with flag "--file-token"
// WHEN: ResourceName rejects it
err := ResourceName("", "--file-token")
// THEN: the error message contains the flag name for user-facing diagnostics
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--file-token") {
t.Errorf("error should contain flag name, got: %s", err.Error())
}
}
func TestEncodePathSegment_EncodesSpecialCharacters(t *testing.T) {
for _, tt := range []struct {
name string
input string
want string
}{
// ── GIVEN: safe characters → THEN: unchanged ──
{"normal", "om_abc123", "om_abc123"},
{"empty", "", ""},
// ── GIVEN: URL-special characters → THEN: percent-encoded ──
{"slash", "a/b", "a%2Fb"},
{"space", "hello world", "hello%20world"},
{"question mark", "id?foo", "id%3Ffoo"},
{"hash", "id#bar", "id%23bar"},
{"dot-dot", "../admin", "..%2Fadmin"},
{"percent", "50%done", "50%25done"},
{"unicode", "报告", "%E6%8A%A5%E5%91%8A"},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN: EncodePathSegment encodes the input
got := EncodePathSegment(tt.input)
// THEN: output matches expected encoding
if got != tt.want {
t.Errorf("EncodePathSegment(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/charcheck"
)
// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL).
// Private CSI sequences (e.g. ESC[?25l) use the extended parameter byte range [0-9;?>=!].
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?>=!]*[a-zA-Z]|\x1b\][^\x07]*\x07`)
// SanitizeForTerminal strips ANSI escape sequences, C0 control characters
// (except \n and \t), and dangerous Unicode from text, preserving the actual
// readable content. It should be applied to table format output and stderr
// messages, but NOT to json/ndjson output where programmatic consumers need
// the raw data.
//
// API responses may contain injected ANSI sequences that clear the screen,
// fake a colored "OK" status, or change the terminal title. In AI Agent
// scenarios, such injections can also pollute the LLM's context window
// with misleading output.
func SanitizeForTerminal(text string) string {
if strings.ContainsRune(text, '\x1b') {
text = ansiEscape.ReplaceAllString(text, "")
}
var b strings.Builder
b.Grow(len(text))
for _, r := range text {
switch {
case r == '\n' || r == '\t':
b.WriteRune(r)
case r < 0x20 || r == 0x7f:
continue
case charcheck.IsDangerousUnicode(r):
continue
default:
b.WriteRune(r)
}
}
return b.String()
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"testing"
"github.com/larksuite/cli/internal/charcheck"
)
func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) {
for _, tt := range []struct {
name string
input string
want string
}{
// ── GIVEN: normal text → THEN: unchanged ──
{"plain text", "hello world", "hello world"},
{"unicode text", "你好世界", "你好世界"},
{"empty", "", ""},
// ── GIVEN: tab and newline → THEN: preserved ──
{"preserve tab", "col1\tcol2", "col1\tcol2"},
{"preserve newline", "line1\nline2", "line1\nline2"},
// ── GIVEN: ANSI CSI sequences → THEN: stripped, text preserved ──
{"clear screen", "before\x1b[2Jafter", "beforeafter"},
{"red color", "before\x1b[31mred\x1b[0mafter", "beforeredafter"},
{"bold", "before\x1b[1mbold\x1b[0mafter", "beforeboldafter"},
{"cursor move", "before\x1b[10;20Hafter", "beforeafter"},
{"multiple sequences", "\x1b[31m\x1b[1mhello\x1b[0m", "hello"},
// ── GIVEN: ANSI OSC sequences → THEN: stripped ──
{"OSC title change", "before\x1b]0;evil title\x07after", "beforeafter"},
{"OSC with text", "text\x1b]2;new title\x07more", "textmore"},
// ── GIVEN: C0 control characters → THEN: stripped ──
{"null byte", "hello\x00world", "helloworld"},
{"bell", "hello\x07world", "helloworld"},
{"backspace", "hello\x08world", "helloworld"},
{"escape alone", "hello\x1bworld", "helloworld"},
{"carriage return", "hello\rworld", "helloworld"},
{"DEL", "hello\x7fworld", "helloworld"},
// ── GIVEN: dangerous Unicode → THEN: stripped ──
{"zero width space", "hello\u200Bworld", "helloworld"},
{"BOM", "hello\uFEFFworld", "helloworld"},
{"bidi RLO", "hello\u202Eworld", "helloworld"},
{"bidi LRI", "hello\u2066world", "helloworld"},
{"line separator", "hello\u2028world", "helloworld"},
// ── GIVEN: mixed attack payload → THEN: all dangerous content stripped ──
{"ansi + null + bidi", "\x1b[31m\x00\u202Ehello\x1b[0m", "hello"},
{"realistic injection", "Status: \x1b[32mOK\x1b[0m (fake)", "Status: OK (fake)"},
} {
t.Run(tt.name, func(t *testing.T) {
// WHEN: SanitizeForTerminal processes the input
got := SanitizeForTerminal(tt.input)
// THEN: output matches expected sanitized result
if got != tt.want {
t.Errorf("SanitizeForTerminal(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) {
// ── GIVEN: known dangerous Unicode code points → THEN: returns true ──
dangerous := []rune{
0x200B, 0x200C, 0x200D, // zero-width
0xFEFF, // BOM
0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // bidi
0x2028, 0x2029, // separators
0x2066, 0x2067, 0x2068, 0x2069, // isolates
}
for _, r := range dangerous {
if !charcheck.IsDangerousUnicode(r) {
t.Errorf("charcheck.IsDangerousUnicode(%U) = false, want true", r)
}
}
// ── GIVEN: safe Unicode code points → THEN: returns false ──
safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070}
for _, r := range safe {
if charcheck.IsDangerousUnicode(r) {
t.Errorf("charcheck.IsDangerousUnicode(%U) = true, want false", r)
}
}
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
const (
defaultDownloadMaxRedirects = 5
)
// DownloadHTTPClientOptions controls redirect and scheme behavior for
// untrusted-source downloads.
type DownloadHTTPClientOptions struct {
// AllowHTTP controls whether plain HTTP URLs are permitted.
// If false, any HTTP URL (initial or redirect target) is rejected.
AllowHTTP bool
// MaxRedirects limits follow-up redirects. Zero or negative uses default.
MaxRedirects int
}
func isRestrictedDownloadIP(ip net.IP) bool {
if ip == nil {
return true
}
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 10 || v4[0] == 127 {
return true
}
if v4[0] == 169 && v4[1] == 254 {
return true
}
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
return true
}
if v4[0] == 192 && v4[1] == 168 {
return true
}
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { // RFC6598 CGNAT
return true
}
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
return true
}
return false
}
if ip.IsPrivate() {
return true
}
ip16 := ip.To16()
if ip16 == nil {
return true
}
if ip16[0]&0xfe == 0xfc { // fc00::/7 unique local address
return true
}
return false
}
// ValidateDownloadSourceURL validates a download URL and blocks local/internal targets.
func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil || u == nil {
return fmt.Errorf("invalid URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("only http/https URLs are supported")
}
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
if host == "" {
return fmt.Errorf("URL host is required")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return fmt.Errorf("local/internal host is not allowed")
}
if ip := net.ParseIP(host); ip != nil {
if isRestrictedDownloadIP(ip) {
return fmt.Errorf("local/internal host is not allowed")
}
return nil
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("failed to resolve host")
}
if len(ips) == 0 {
return fmt.Errorf("failed to resolve host")
}
for _, ip := range ips {
if isRestrictedDownloadIP(ip) {
return fmt.Errorf("local/internal host is not allowed")
}
}
return nil
}
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
// and connection rules for untrusted URLs.
func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *http.Client {
if base == nil {
base = &http.Client{}
}
if opts.MaxRedirects <= 0 {
opts.MaxRedirects = defaultDownloadMaxRedirects
}
cloned := *base
cloned.Transport = cloneDownloadTransport(base.Transport)
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= opts.MaxRedirects {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 {
prev := via[len(via)-1]
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
return fmt.Errorf("redirect from https to http is not allowed")
}
}
if !opts.AllowHTTP && !strings.EqualFold(req.URL.Scheme, "https") {
return fmt.Errorf("only https URLs are supported")
}
if err := ValidateDownloadSourceURL(req.Context(), req.URL.String()); err != nil {
return fmt.Errorf("blocked redirect target: %w", err)
}
return nil
}
return &cloned
}
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
var cloned *http.Transport
if src, ok := base.(*http.Transport); ok && src != nil {
cloned = src.Clone()
} else {
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
cloned = def.Clone()
} else {
cloned = &http.Transport{}
}
}
origDial := cloned.DialContext
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialConn(ctx, origDial, network, addr)
if err != nil {
return nil, err
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
if cloned.DialTLSContext != nil {
origDialTLS := cloned.DialTLSContext
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialConn(ctx, origDialTLS, network, addr)
if err != nil {
return nil, err
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
}
return cloned
}
// DialContextFunc is the signature for DialContext / DialTLSContext.
type DialContextFunc func(ctx context.Context, network, addr string) (net.Conn, error)
// WrapDialContextWithIPCheck wraps a DialContext function to validate the
// remote IP after connection, rejecting local/internal addresses (SSRF protection).
func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialConn(ctx, origDial, network, addr)
if err != nil {
return nil, err
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
}
func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
if dialFn != nil {
return dialFn(ctx, network, addr)
}
var d net.Dialer
return d.DialContext(ctx, network, addr)
}
func validateConnRemoteIP(conn net.Conn) error {
if conn == nil {
return fmt.Errorf("nil connection")
}
raddr := conn.RemoteAddr()
if raddr == nil {
return fmt.Errorf("missing remote address")
}
host, _, err := net.SplitHostPort(raddr.String())
if err != nil {
host = raddr.String()
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return fmt.Errorf("invalid remote IP")
}
if isRestrictedDownloadIP(ip) {
return fmt.Errorf("local/internal host is not allowed")
}
return nil
}