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
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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user