diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 05818af6e..7b4a23464 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -20,4 +20,8 @@ const ( CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" + + CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" + CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS" + CliCAPath = "LARKSUITE_CLI_CA_PATH" ) diff --git a/internal/proxyplugin/config.go b/internal/proxyplugin/config.go new file mode 100644 index 000000000..1731f7296 --- /dev/null +++ b/internal/proxyplugin/config.go @@ -0,0 +1,240 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode. +// +// It supports: +// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy +// - trusting an additional root CA PEM bundle for MITM/inspection proxies +// +// Environment variables override matching values from proxy_config.json. +package proxyplugin + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" +) + +// ConfigFileName is the fixed config file name under core.GetConfigDir(). +const ( + ConfigFileName = "proxy_config.json" +) + +// Config is the on-disk config format. Keys intentionally mirror env var names. +type Config struct { + // Enable turns on proxy plugin transport handling. + Enable bool `json:"LARKSUITE_CLI_PROXY_ENABLE"` + + // Proxy is the fixed HTTP proxy address used for all outbound requests. + Proxy string `json:"LARKSUITE_CLI_PROXY_ADDRESS"` + + // CAPath points to an extra PEM bundle trusted for proxy TLS interception. + CAPath string `json:"LARKSUITE_CLI_CA_PATH"` +} + +// Path returns the absolute path to the proxy plugin config file. +func Path() string { + return filepath.Join(core.GetConfigDir(), ConfigFileName) +} + +// loadOnce guards one-time proxy config loading for process-wide transport reuse. +var loadOnce sync.Once + +// loadCfg stores the cached proxy config after the first successful Load call. +var loadCfg *Config + +// loadErr stores the cached Load error observed during the first load attempt. +var loadErr error + +// Load reads ~/.lark-cli/proxy_config.json once and caches the parsed result. +// Environment variables (CliProxyEnable/CliProxyAddress/CliCAPath) take precedence over config file values. +// +// Returns (nil, nil) only when: +// - the config file does not exist AND +// - none of the proxy-related env vars are present. +func Load() (*Config, error) { + loadOnce.Do(func() { + // Start from env-only config if any proxy env var is present. + cfg, hasEnv, err := loadFromEnv() + if err != nil { + loadErr = err + return + } + + p := Path() + if _, err := vfs.Stat(p); err != nil { + if errors.Is(err, os.ErrNotExist) { + // No file: return env-only config (if any), else nil. + if hasEnv { + loadCfg = cfg + } else { + loadCfg = nil + } + loadErr = nil + return + } + loadErr = fmt.Errorf("failed to stat proxy plugin config %q: %w", p, err) + return + } + b, err := vfs.ReadFile(p) + if err != nil { + loadErr = fmt.Errorf("failed to read proxy plugin config %q: %w", p, err) + return + } + var fileCfg Config + if err := json.Unmarshal(b, &fileCfg); err != nil { + loadErr = fmt.Errorf("invalid proxy plugin config %q: %w", p, err) + return + } + + // Merge: file base + env overrides. + if cfg == nil { + cfg = &fileCfg + } else { + *cfg = fileCfg + applyEnvOverrides(cfg) + } + loadCfg = cfg + }) + return loadCfg, loadErr +} + +// Enabled reports whether proxy plugin mode is enabled. +func (c *Config) Enabled() bool { return c != nil && c.Enable } + +// loadFromEnv builds a config from proxy-related environment variables only. +// It reports whether any proxy-related environment variable was present. +func loadFromEnv() (*Config, bool, error) { + _, hasEnable := os.LookupEnv(envvars.CliProxyEnable) + _, hasProxy := os.LookupEnv(envvars.CliProxyAddress) + _, hasCA := os.LookupEnv(envvars.CliCAPath) + hasAny := hasEnable || hasProxy || hasCA + if !hasAny { + return nil, false, nil + } + cfg := &Config{} + if err := applyEnvOverrides(cfg); err != nil { + return nil, true, err + } + return cfg, true, nil +} + +// applyEnvOverrides copies proxy-related environment variable values into cfg. +func applyEnvOverrides(cfg *Config) error { + if v, ok := os.LookupEnv(envvars.CliProxyEnable); ok { + b, err := parseBoolEnv(envvars.CliProxyEnable, v) + if err != nil { + return err + } + cfg.Enable = b + } + if v, ok := os.LookupEnv(envvars.CliProxyAddress); ok { + cfg.Proxy = v + } + if v, ok := os.LookupEnv(envvars.CliCAPath); ok { + cfg.CAPath = v + } + return nil +} + +// parseBoolEnv accepts common boolean spellings used in environment variables. +func parseBoolEnv(name, raw string) (bool, error) { + s := strings.TrimSpace(strings.ToLower(raw)) + if s == "" { + // Treat empty as false when explicitly present. + return false, nil + } + switch s { + case "1", "true", "on", "yes", "y": + return true, nil + case "0", "false", "off", "no", "n": + return false, nil + } + if b, err := strconv.ParseBool(s); err == nil { + return b, nil + } + return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw) +} + +// proxyURL validates the fixed configured proxy configuration and returns its URL. +func (c *Config) proxyURL() (*url.URL, error) { + raw := strings.TrimSpace(c.Proxy) + if raw == "" { + return nil, fmt.Errorf("%s is empty", envvars.CliProxyAddress) + } + redacted := redactProxyURL(raw) + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliProxyAddress, redacted, err) + } + if u.Scheme != "http" { + return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliProxyAddress, redacted) + } + if u.Host == "" { + return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliProxyAddress, redacted) + } + // Security hardening: only allow a loopback proxy. This prevents accidental + // cross-machine proxying of credentials/traffic. + if u.Hostname() != "127.0.0.1" { + return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliProxyAddress, redacted) + } + if u.Port() == "" { + return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliProxyAddress, redacted) + } + if u.Path != "" { + return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliProxyAddress, redacted) + } + if u.RawQuery != "" { + return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliProxyAddress, redacted) + } + if u.Fragment != "" { + return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliProxyAddress, redacted) + } + return u, nil +} + +// redactProxyURL masks userinfo (username:password) in a proxy URL. +// Handles both scheme-prefixed ("http://user:pass@host") and bare formats. +func redactProxyURL(raw string) string { + u, err := url.Parse(raw) + if err == nil && u.User != nil { + u.User = url.User("***") + return u.String() + } + // Fallback: handle "user:pass@proxy:8080" + if at := strings.LastIndex(raw, "@"); at > 0 { + return "***@" + raw[at+1:] + } + return raw +} + +// ApplyToTransport clones base and applies proxy plugin settings to the clone. +// Caller owns the returned *http.Transport. +func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) { + if base == nil { + base = http.DefaultTransport.(*http.Transport) + } + u, err := c.proxyURL() + if err != nil { + return nil, err + } + + t := base.Clone() + t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars + if err := applyExtraRootCA(t, c.CAPath); err != nil { + return nil, err + } + return t, nil +} diff --git a/internal/proxyplugin/config_test.go b/internal/proxyplugin/config_test.go new file mode 100644 index 000000000..06b2e3bc9 --- /dev/null +++ b/internal/proxyplugin/config_test.go @@ -0,0 +1,242 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/larksuite/cli/internal/envvars" +) + +// unsetEnv clears key for the duration of the test and restores its original value. +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, had := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if had { + _ = os.Setenv(key, old) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests. +func unsetProxyPluginEnv(t *testing.T) { + t.Helper() + unsetEnv(t, envvars.CliProxyEnable) + unsetEnv(t, envvars.CliProxyAddress) + unsetEnv(t, envvars.CliCAPath) +} + +// writeFile creates parent directories and writes test data for fixtures. +func writeFile(t *testing.T, path string, data []byte, perm os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, data, perm); err != nil { + t.Fatalf("WriteFile: %v", err) + } +} + +// TestLoad_MissingFileReturnsNil verifies that Load reports no config when no file +// or proxy environment overrides exist. +func TestLoad_MissingFileReturnsNil(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetProxyPluginEnv(t) + // TestLoad_MissingFileReturnsNil must reset loadOnce, loadCfg, and loadErr + // because multiple tests in this package share the package-level Load() + // cache via sync.Once. + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg != nil { + t.Fatalf("Load() = %#v, want nil (missing file)", cfg) + } +} + +// TestApplyToTransport_SetsProxy verifies that a valid proxy config installs a fixed proxy. +func TestApplyToTransport_SetsProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetProxyPluginEnv(t) + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + + base := http.DefaultTransport.(*http.Transport) + tr, err := cfg.ApplyToTransport(base) + if err != nil { + t.Fatalf("ApplyToTransport() error = %v", err) + } + if tr.Proxy == nil { + t.Fatal("Proxy func is nil, want fixed proxy") + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u) + } +} + +// TestLoad_RejectsNonLoopbackProxy verifies that proxy mode rejects non-loopback proxies. +func TestLoad_RejectsNonLoopbackProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + unsetProxyPluginEnv(t) + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://10.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + _, err = cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport)) + if err == nil { + t.Fatal("ApplyToTransport() error = nil, want invalid proxy host error") + } +} + +// TestConfig_ProxyURLRejectsUnsupportedParts verifies the configured proxy validator +// rejects URLs with missing ports, paths, queries, and fragments. +func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + { + name: "missing explicit port", + raw: "http://127.0.0.1", + want: "explicit port is required", + }, + { + name: "trailing slash path", + raw: "http://127.0.0.1:3128/", + want: "path is not allowed", + }, + { + name: "query string", + raw: "http://127.0.0.1:3128?foo=bar", + want: "query is not allowed", + }, + { + name: "fragment", + raw: "http://127.0.0.1:3128#frag", + want: "fragment is not allowed", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + _, err := (&Config{Proxy: tt.raw}).proxyURL() + if err == nil { + t.Fatalf("proxyURL() error = nil, want substring %q", tt.want) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("proxyURL() error = %q, want substring %q", err, tt.want) + } + }) + } +} + +// TestLoad_EnvOnlyConfig verifies that proxy settings can come entirely from environment variables. +func TestLoad_EnvOnlyConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + + t.Setenv(envvars.CliProxyEnable, "true") + t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:7777") + t.Setenv(envvars.CliCAPath, "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } + tr, err := cfg.ApplyToTransport(http.DefaultTransport.(*http.Transport)) + if err != nil { + t.Fatalf("ApplyToTransport() error = %v", err) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:7777" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:7777", u) + } +} + +// TestLoad_EnvOverridesFile verifies that proxy environment variables override file values. +func TestLoad_EnvOverridesFile(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + + // File enables with one proxy. + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + // Env overrides: disable + different proxy (should be irrelevant once disabled). + t.Setenv(envvars.CliProxyEnable, "false") + t.Setenv(envvars.CliProxyAddress, "http://127.0.0.1:9999") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg == nil { + t.Fatalf("Load() = nil, want non-nil (file exists)") + } + if cfg.Enabled() { + t.Fatalf("cfg.Enabled() = true, want false (env override)") + } +} diff --git a/internal/proxyplugin/tls_ca.go b/internal/proxyplugin/tls_ca.go new file mode 100644 index 000000000..75b7665d1 --- /dev/null +++ b/internal/proxyplugin/tls_ca.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/binding" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" +) + +// applyExtraRootCA augments t with an additional PEM bundle used for configured proxy +// TLS interception. +func applyExtraRootCA(t *http.Transport, caPath string) error { + caPath = strings.TrimSpace(caPath) + if caPath == "" { + return nil + } + if !filepath.IsAbs(caPath) { + return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliCAPath, caPath) + } + safeCAPath, err := binding.AssertSecurePath(binding.AuditParams{ + TargetPath: caPath, + Label: envvars.CliCAPath, + AllowReadableByOthers: true, + }) + if err != nil { + return fmt.Errorf("unsafe %s %q: %w", envvars.CliCAPath, caPath, err) + } + pemBytes, err := vfs.ReadFile(safeCAPath) + if err != nil { + return fmt.Errorf("failed to read %s %q: %w", envvars.CliCAPath, caPath, err) + } + + // Start from system pool when possible; if unavailable, create a new pool. + pool, _ := x509.SystemCertPool() + if pool == nil { + pool = x509.NewCertPool() + } + if ok := pool.AppendCertsFromPEM(pemBytes); !ok { + return fmt.Errorf("invalid %s %q: no certificates parsed from PEM", envvars.CliCAPath, caPath) + } + + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{} + } else { + // Clone to avoid mutating shared config from the base transport. + t.TLSClientConfig = t.TLSClientConfig.Clone() + } + if t.TLSClientConfig.MinVersion == 0 || t.TLSClientConfig.MinVersion < tls.VersionTLS12 { + t.TLSClientConfig.MinVersion = tls.VersionTLS12 + } + t.TLSClientConfig.RootCAs = pool + return nil +} diff --git a/internal/proxyplugin/tls_ca_test.go b/internal/proxyplugin/tls_ca_test.go new file mode 100644 index 000000000..31e25c23b --- /dev/null +++ b/internal/proxyplugin/tls_ca_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// mustCreateTestCertPEM generates a short-lived self-signed CA certificate for tests. +func mustCreateTestCertPEM(t *testing.T) []byte { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("GenerateKey() error = %v", err) + } + + der, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "proxyplugin-test-ca", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + }, &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + CommonName: "proxyplugin-test-ca", + }, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + }, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate() error = %v", err) + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// TestApplyExtraRootCA_EmptyPathIsNoop verifies that an empty CA path leaves the transport unchanged. +func TestApplyExtraRootCA_EmptyPathIsNoop(t *testing.T) { + tr := &http.Transport{} + + if err := applyExtraRootCA(tr, " "); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig != nil { + t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig) + } +} + +// TestApplyExtraRootCA_RejectsRelativePath verifies that CA paths must be absolute. +func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) { + tr := &http.Transport{} + + err := applyExtraRootCA(tr, "ca.pem") + if err == nil || !strings.Contains(err.Error(), "must be an absolute path") { + t.Fatalf("applyExtraRootCA() error = %v, want absolute-path error", err) + } +} + +// TestApplyExtraRootCA_RejectsMissingFile verifies missing PEM bundles fail before file reads. +func TestApplyExtraRootCA_RejectsMissingFile(t *testing.T) { + tr := &http.Transport{} + + err := applyExtraRootCA(tr, filepath.Join(t.TempDir(), "missing.pem")) + if err == nil || !strings.Contains(err.Error(), "unsafe") { + t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err) + } +} + +// TestApplyExtraRootCA_RejectsInvalidPEM verifies validation of malformed PEM bundles. +func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "invalid.pem") + writeFile(t, caPath, []byte("not a pem"), 0600) + + tr := &http.Transport{} + err := applyExtraRootCA(tr, caPath) + if err == nil || !strings.Contains(err.Error(), "no certificates parsed from PEM") { + t.Fatalf("applyExtraRootCA() error = %v, want invalid PEM error", err) + } +} + +// TestApplyExtraRootCA_RejectsInsecureCAPath verifies CA paths are safety-checked +// before reading the configured file. +func TestApplyExtraRootCA_RejectsInsecureCAPath(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + if err := os.Chmod(caPath, 0666); err != nil { + t.Fatalf("Chmod() error = %v", err) + } + + tr := &http.Transport{} + err := applyExtraRootCA(tr, caPath) + if err == nil || !strings.Contains(err.Error(), "unsafe") { + t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err) + } + if tr.TLSClientConfig != nil { + t.Fatalf("TLSClientConfig = %#v, want nil", tr.TLSClientConfig) + } +} + +// TestApplyExtraRootCA_SetsTLSConfigWhenMissing verifies initialization of TLSClientConfig when absent. +func TestApplyExtraRootCA_SetsTLSConfigWhenMissing(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + tr := &http.Transport{} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig == nil { + t.Fatal("TLSClientConfig = nil, want initialized config") + } + if tr.TLSClientConfig.RootCAs == nil { + t.Fatal("RootCAs = nil, want cert pool") + } +} + +// TestApplyExtraRootCA_ClonesExistingTLSConfig verifies cloning when the base transport already has TLS settings. +func TestApplyExtraRootCA_ClonesExistingTLSConfig(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + original := &tls.Config{ServerName: "open.feishu.cn"} + tr := &http.Transport{TLSClientConfig: original} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig == original { + t.Fatal("TLSClientConfig pointer reused, want clone") + } + if tr.TLSClientConfig.ServerName != original.ServerName { + t.Fatalf("ServerName = %q, want %q", tr.TLSClientConfig.ServerName, original.ServerName) + } + if tr.TLSClientConfig.RootCAs == nil { + t.Fatal("RootCAs = nil, want cert pool") + } +} + +// TestApplyExtraRootCA_PreservesHigherTLSMinVersion verifies that adding a CA +// does not relax an existing stricter TLS version floor. +func TestApplyExtraRootCA_PreservesHigherTLSMinVersion(t *testing.T) { + caPath := filepath.Join(t.TempDir(), "ca.pem") + writeFile(t, caPath, mustCreateTestCertPEM(t), 0600) + + tr := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13}} + if err := applyExtraRootCA(tr, caPath); err != nil { + t.Fatalf("applyExtraRootCA() error = %v", err) + } + if tr.TLSClientConfig.MinVersion != tls.VersionTLS13 { + t.Fatalf("MinVersion = %x, want %x", tr.TLSClientConfig.MinVersion, tls.VersionTLS13) + } +} diff --git a/internal/proxyplugin/transport.go b/internal/proxyplugin/transport.go new file mode 100644 index 000000000..d85965571 --- /dev/null +++ b/internal/proxyplugin/transport.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "fmt" + "net/http" + "net/url" + "sync" +) + +// proxyPluginTransport is a fixed-proxy clone of http.DefaultTransport (with optional +// custom root CA), lazily built on first use when proxy plugin mode is enabled. +var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) + +// cachedBlockedTransport is a fail-closed transport cached on first use when +// the proxy plugin config exists but is invalid. This avoids cloning +// http.DefaultTransport on every SharedTransport call. +var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) + +func buildBlockedTransport() http.RoundTripper { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return blockedRoundTripper{err: fmt.Errorf("proxy plugin config is invalid and http.DefaultTransport is %T, want *http.Transport: %w", http.DefaultTransport, loadErr)} + } + return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", loadErr)) +} + +func buildProxyPluginTransport() http.RoundTripper { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return blockedRoundTripper{err: fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport)} + } + + cfg, err := Load() + if err != nil { + // Fail closed: config file exists but is malformed/unreadable — do not + // silently fall back to direct egress. + return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err)) + } + if cfg == nil || !cfg.Enabled() { + return def + } + t, err := cfg.ApplyToTransport(def) + if err != nil { + // Fail closed: do not silently fall back to direct egress when the + // operator explicitly enabled proxy plugin mode. + return blockedTransport(def, fmt.Errorf("proxy plugin enabled but config is invalid: %w", err)) + } + return t +} + +// SharedTransport returns the proxy plugin transport when proxy plugin mode is +// configured. The bool return is false when the plugin is not configured or not enabled. +func SharedTransport() (http.RoundTripper, bool) { + cfg, err := Load() + if err != nil { + return cachedBlockedTransport(), true + } + if cfg == nil || !cfg.Enabled() { + return nil, false + } + return proxyPluginTransport(), true +} + +type blockedRoundTripper struct { + err error +} + +func (b blockedRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, b.err +} + +func blockedTransport(base *http.Transport, err error) *http.Transport { + blocked := base.Clone() + blocked.Proxy = func(*http.Request) (*url.URL, error) { + return nil, err + } + return blocked +} diff --git a/internal/proxyplugin/transport_test.go b/internal/proxyplugin/transport_test.go new file mode 100644 index 000000000..c00ad2e38 --- /dev/null +++ b/internal/proxyplugin/transport_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "io" + "net/http" + "net/url" + "strings" + "sync" + "testing" +) + +func resetProxyPluginState() { + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) + cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) +} + +func TestSharedTransport_NotConfigured(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + tr, ok := SharedTransport() + if ok { + t.Fatalf("SharedTransport() ok = true, want false") + } + if tr != nil { + t.Fatalf("SharedTransport() transport = %T, want nil", tr) + } +} + +func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + cfgPath := Path() + writeFile(t, cfgPath, []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "" +}`), 0600) + + rt, ok := SharedTransport() + if !ok { + t.Fatal("SharedTransport() ok = false, want true") + } + tr, ok := rt.(*http.Transport) + if !ok { + t.Fatalf("SharedTransport() = %T, want *http.Transport", rt) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil { + t.Fatalf("Proxy() error = %v", err) + } + if u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, want http://127.0.0.1:3128", u) + } +} + +func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{}) + defer restoreDefaultTransport() + + writeFile(t, Path(), []byte(`{`), 0600) + + rt, ok := SharedTransport() + if !ok { + t.Fatal("SharedTransport() ok = false, want true") + } + if rt == http.DefaultTransport { + t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{`), 0600) + + a, ok := SharedTransport() + if !ok { + t.Fatal("SharedTransport() ok = false, want true") + } + b, ok := SharedTransport() + if !ok { + t.Fatal("SharedTransport() ok = false, want true") + } + if a != b { + t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached") + } +} + +func TestBuildProxyPluginTransport_InvalidConfigFailsClosed(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{`), 0600) + + rt := buildProxyPluginTransport() + if rt == http.DefaultTransport { + t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + restoreDefaultTransport := replaceDefaultTransport(okRoundTripper{}) + defer restoreDefaultTransport() + + rt := buildProxyPluginTransport() + if rt == http.DefaultTransport { + t.Fatalf("buildProxyPluginTransport() returned http.DefaultTransport, want fail-closed transport") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() error = nil, response = %#v; want fail-closed error", resp) + } + if resp != nil { + t.Fatalf("RoundTrip() response = %#v, want nil", resp) + } +} + +type okRoundTripper struct{} + +func (okRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(""))}, nil +} + +func replaceDefaultTransport(rt http.RoundTripper) func() { + original := http.DefaultTransport + http.DefaultTransport = rt + return func() { + http.DefaultTransport = original + } +} diff --git a/internal/util/proxy.go b/internal/util/proxy.go index d9e251859..a60cf1e66 100644 --- a/internal/util/proxy.go +++ b/internal/util/proxy.go @@ -11,8 +11,11 @@ import ( "os" "strings" "sync" + + "github.com/larksuite/cli/internal/proxyplugin" ) +// Proxy environment constants control shared transport proxy behavior. const ( // EnvNoProxy disables automatic proxy support when set to any non-empty value. EnvNoProxy = "LARK_CLI_NO_PROXY" @@ -36,6 +39,7 @@ func DetectProxyEnv() (key, value string) { return "", "" } +// proxyWarningOnce ensures proxy environment warnings are emitted at most once. var proxyWarningOnce sync.Once // redactProxyURL masks userinfo (username:password) in a proxy URL. @@ -99,6 +103,11 @@ var noProxyTransport = sync.OnceValue(func() *http.Transport { // goroutines are reused; cloning per call leaks them until IdleConnTimeout // (~90s) fires. func SharedTransport() http.RoundTripper { + // proxy plugin mode overrides all other proxy behavior (env proxies and + // LARK_CLI_NO_PROXY), per operator intent. + if t, ok := proxyplugin.SharedTransport(); ok { + return t + } if os.Getenv(EnvNoProxy) != "" { return noProxyTransport() } diff --git a/internal/util/proxy_test.go b/internal/util/proxy_test.go index f78720963..c84636445 100644 --- a/internal/util/proxy_test.go +++ b/internal/util/proxy_test.go @@ -6,11 +6,42 @@ package util import ( "bytes" "net/http" + "os" "sync" "testing" + + "github.com/larksuite/cli/internal/envvars" ) +// unsetEnv clears key for the duration of the test and restores its original value. +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, had := os.LookupEnv(key) + _ = os.Unsetenv(key) + t.Cleanup(func() { + if had { + _ = os.Setenv(key, old) + } else { + _ = os.Unsetenv(key) + } + }) +} + +// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests. +func unsetProxyPluginEnv(t *testing.T) { + t.Helper() + // Ensure developer machine env doesn't accidentally enable proxy plugin mode + // and change expectations for SharedTransport(). + unsetEnv(t, envvars.CliProxyEnable) + unsetEnv(t, envvars.CliProxyAddress) + unsetEnv(t, envvars.CliCAPath) +} + +// TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior. func TestDetectProxyEnv(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + // Clear all proxy env vars first for _, k := range proxyEnvKeys { t.Setenv(k, "") @@ -28,7 +59,10 @@ func TestDetectProxyEnv(t *testing.T) { } } +// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport. func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) t.Setenv(EnvNoProxy, "") tr := SharedTransport() if tr != http.DefaultTransport { @@ -36,7 +70,10 @@ func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) { } } +// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport. func TestSharedTransport_NoProxyReturnsClone(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) t.Setenv(EnvNoProxy, "1") tr := SharedTransport() if tr == http.DefaultTransport { @@ -51,7 +88,10 @@ func TestSharedTransport_NoProxyReturnsClone(t *testing.T) { } } +// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport. func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) t.Setenv(EnvNoProxy, "1") a := SharedTransport() b := SharedTransport() @@ -60,7 +100,10 @@ func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) { } } +// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy. func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) // Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating // the no-proxy singleton), then unsets it. Subsequent calls must return // http.DefaultTransport, NOT the cached no-proxy clone. @@ -77,7 +120,10 @@ func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { } } +// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies. func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") t.Setenv(EnvNoProxy, "1") @@ -90,7 +136,10 @@ func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) { } } +// TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning. func TestWarnIfProxied_WithProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) // Reset the once guard for this test proxyWarningOnce = sync.Once{} @@ -111,7 +160,10 @@ func TestWarnIfProxied_WithProxy(t *testing.T) { } } +// TestWarnIfProxied_WithoutProxy verifies that no warning is emitted without proxy settings. func TestWarnIfProxied_WithoutProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) proxyWarningOnce = sync.Once{} for _, k := range proxyEnvKeys { @@ -126,7 +178,10 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) { } } +// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings. func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://proxy:8080") @@ -140,7 +195,10 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { } } +// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once. func TestWarnIfProxied_OnlyOnce(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) proxyWarningOnce = sync.Once{} t.Setenv("HTTP_PROXY", "http://proxy:1234") @@ -160,7 +218,10 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) { } } +// TestRedactProxyURL verifies redaction of proxy credentials across supported formats. func TestRedactProxyURL(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) tests := []struct { input string want string @@ -183,7 +244,10 @@ func TestRedactProxyURL(t *testing.T) { } } +// TestWarnIfProxied_RedactsCredentials verifies that warning output never leaks credentials. func TestWarnIfProxied_RedactsCredentials(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080")