diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index a32dee53..1d159275 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -6,7 +6,6 @@ package config import ( "context" "fmt" - "net/http" "github.com/charmbracelet/huh" "github.com/larksuite/cli/internal/build" @@ -17,6 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" ) // configInitResult holds the result of the interactive config init flow. @@ -177,7 +177,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } // Step 1: Request app registration (begin) - httpClient := &http.Client{} + // Use the shared proxy-plugin-aware transport so registration traffic is not + // a bypass of proxy plugin mode. + httpClient := util.NewHTTPClient(0) authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) if err != nil { return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index 9314ebfc..f96cdb6a 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -20,6 +20,7 @@ import ( "github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/update" + "github.com/larksuite/cli/internal/util" ) // DoctorOptions holds inputs for the doctor command. @@ -152,7 +153,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) } } - httpClient := &http.Client{} + // Use the shared proxy-plugin-aware transport so connectivity checks reflect + // the real egress path (and are blocked when proxy plugin fails closed). + httpClient := util.NewHTTPClient(0) mcpURL := ep.MCP + "/mcp" type probeResult struct { diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 05818af6..7b4a2346 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 00000000..46c160d1 --- /dev/null +++ b/internal/proxyplugin/config.go @@ -0,0 +1,257 @@ +// 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/binding" + "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 + } + // Security hardening: this config dictates where ALL outbound CLI traffic + // egresses and which extra CA is trusted, so a file another local user or + // process can tamper with (symlink, foreign owner, group/world-writable) + // could redirect credential traffic. Audit it the same way the CA file is. + safePath, err := binding.AssertSecurePath(binding.AuditParams{ + TargetPath: p, + Label: ConfigFileName, + AllowReadableByOthers: true, // config is not a secret; only writability/owner/symlink matter + }) + if err != nil { + loadErr = fmt.Errorf("unsafe proxy plugin config %q: %w", p, err) + return + } + b, err := vfs.ReadFile(safePath) + 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 { + // Do not wrap the raw url.Parse error: its string embeds the original + // URL, which can contain userinfo (user:password). Return a redacted, + // generic message instead. + return nil, fmt.Errorf("invalid %s %q: malformed URL", envvars.CliProxyAddress, redacted) + } + 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 00000000..c17c3a49 --- /dev/null +++ b/internal/proxyplugin/config_test.go @@ -0,0 +1,372 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "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)") + } +} + +// TestConfig_ProxyURLMalformedDoesNotLeakUserinfo verifies that a malformed proxy +// URL containing credentials does not leak those credentials in the error string. +// url.Parse error strings embed the original URL, so wrapping them with %w would +// expose user:password. +func TestConfig_ProxyURLMalformedDoesNotLeakUserinfo(t *testing.T) { + // Invalid percent-encoding in host makes url.Parse fail while userinfo is present. + raw := "http://user:s3cret@%zz" + _, err := (&Config{Proxy: raw}).proxyURL() + if err == nil { + t.Fatal("proxyURL() error = nil, want malformed URL error") + } + if strings.Contains(err.Error(), "s3cret") { + t.Fatalf("proxyURL() error leaks password: %q", err) + } + if strings.Contains(err.Error(), "user:") { + t.Fatalf("proxyURL() error leaks username: %q", err) + } + if !strings.Contains(err.Error(), "malformed URL") { + t.Fatalf("proxyURL() error = %q, want it to mention malformed URL", err) + } + // The redacted form should still be present for diagnostics. + if !strings.Contains(err.Error(), "***") { + t.Fatalf("proxyURL() error = %q, want redacted userinfo marker", err) + } +} + +// resetLoadState resets the package-level Load() cache for deterministic tests. +func resetLoadState() { + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil +} + +// TestLoad_RejectsWorldWritableConfig verifies that a world-writable proxy config +// is rejected rather than silently trusted (it could be tampered with by other +// local processes to redirect credential traffic). +func TestLoad_RejectsWorldWritableConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics") + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + resetLoadState() + unsetProxyPluginEnv(t) + + p := Path() + writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + // Chmod (not WriteFile perm) so umask cannot strip the world-writable bit. + if err := os.Chmod(p, 0o666); err != nil { + t.Fatalf("Chmod: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want unsafe-config error for world-writable file") + } + if !strings.Contains(err.Error(), "world-writable") { + t.Fatalf("Load() error = %q, want world-writable rejection", err) + } +} + +// TestLoad_RejectsGroupWritableConfig verifies group-writable configs are rejected. +func TestLoad_RejectsGroupWritableConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permission semantics") + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + resetLoadState() + unsetProxyPluginEnv(t) + + p := Path() + writeFile(t, p, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + if err := os.Chmod(p, 0o660); err != nil { + t.Fatalf("Chmod: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want unsafe-config error for group-writable file") + } + if !strings.Contains(err.Error(), "group-writable") { + t.Fatalf("Load() error = %q, want group-writable rejection", err) + } +} + +// TestLoad_RejectsSymlinkConfig verifies that a symlinked proxy config is rejected, +// preventing redirection of the trusted config path to an attacker-controlled file. +func TestLoad_RejectsSymlinkConfig(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation is privileged on Windows") + } + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + resetLoadState() + unsetProxyPluginEnv(t) + + // Real file lives elsewhere; the config path is a symlink to it. + real := filepath.Join(dir, "real_proxy_config.json") + writeFile(t, real, []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + if err := os.Symlink(real, Path()); err != nil { + t.Fatalf("Symlink: %v", err) + } + + _, err := Load() + if err == nil { + t.Fatal("Load() error = nil, want unsafe-config error for symlinked file") + } + if !strings.Contains(err.Error(), "symlink") { + t.Fatalf("Load() error = %q, want symlink rejection", err) + } +} + +// TestLoad_AcceptsSecureConfig verifies the audit does not break the normal case: +// an owner-only 0600 config still loads. +func TestLoad_AcceptsSecureConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + resetLoadState() + unsetProxyPluginEnv(t) + + writeFile(t, Path(), []byte(`{"LARKSUITE_CLI_PROXY_ENABLE":true,"LARKSUITE_CLI_PROXY_ADDRESS":"http://127.0.0.1:3128"}`), 0600) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v, want nil for secure 0600 config", err) + } + if cfg == nil || !cfg.Enabled() { + t.Fatalf("cfg.Enabled() = %v, want true", cfg) + } +} diff --git a/internal/proxyplugin/tls_ca.go b/internal/proxyplugin/tls_ca.go new file mode 100644 index 00000000..c55dd796 --- /dev/null +++ b/internal/proxyplugin/tls_ca.go @@ -0,0 +1,68 @@ +// 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) + } + + // Augment the system trust store. Do NOT silently discard a SystemCertPool + // error: falling back to an empty pool would make this transport trust ONLY + // the extra CA (dropping all system roots), which narrows trust unexpectedly + // and could break TLS to legitimate endpoints. Fail closed instead. + pool, err := x509.SystemCertPool() + if err != nil { + return fmt.Errorf("failed to load system cert pool for %s: %w", envvars.CliCAPath, err) + } + 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 00000000..31e25c23 --- /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 00000000..9179c468 --- /dev/null +++ b/internal/proxyplugin/transport.go @@ -0,0 +1,90 @@ +// 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 { + return failClosedTransport(fmt.Errorf("proxy plugin config is invalid: %w", loadErr)) +} + +func buildProxyPluginTransport() http.RoundTripper { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + // Cannot clone the stdlib transport. Fail closed with a concrete + // *http.Transport (not a bare RoundTripper) so downcasting callers such + // as util.FallbackTransport cannot silently degrade this into a + // direct-egress transport. + return failClosedTransport(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 +} + +// failClosedTransport returns a *http.Transport that always fails RoundTrip with +// err. It clones http.DefaultTransport when possible (preserving dial/timeout +// tuning); otherwise it builds a minimal transport. Returning a concrete +// *http.Transport (rather than a bare RoundTripper) is required so downcasting +// callers such as util.FallbackTransport cannot silently degrade a fail-closed +// signal into a direct-egress transport. +func failClosedTransport(err error) *http.Transport { + if def, ok := http.DefaultTransport.(*http.Transport); ok { + return blockedTransport(def, err) + } + return &http.Transport{ + Proxy: func(*http.Request) (*url.URL, error) { + return nil, 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 00000000..c4414f8b --- /dev/null +++ b/internal/proxyplugin/transport_test.go @@ -0,0 +1,195 @@ +// 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) + } +} + +// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the +// fail-closed invariant that util.FallbackTransport relies on: even when +// http.DefaultTransport is not an *http.Transport, an invalid proxy config must +// produce a blocked transport that is itself a concrete *http.Transport. If it +// were a bare RoundTripper, util.FallbackTransport would downcast-fail and +// silently degrade it into a direct-egress transport. +func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(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 _, isTransport := rt.(*http.Transport); !isTransport { + t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt) + } + // Must remain fail-closed. + 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/registry/remote.go b/internal/registry/remote.go index b229f641..4bc9d6a8 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -178,7 +179,9 @@ func saveCachedMerged(data []byte, meta CacheMeta) error { // localVersion is sent as data_version query param for server-side version comparison. // Returns (data, reg, err). A nil reg means the version is unchanged (not modified). func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) { - client := &http.Client{Timeout: fetchTimeout} + // Route through the shared proxy-plugin-aware transport so remote API + // definition fetches honor proxy plugin mode instead of bypassing it. + client := util.NewHTTPClient(fetchTimeout) req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil) if err != nil { return nil, nil, err diff --git a/internal/util/proxy.go b/internal/util/proxy.go index d9e25185..d78134e1 100644 --- a/internal/util/proxy.go +++ b/internal/util/proxy.go @@ -11,8 +11,13 @@ import ( "os" "strings" "sync" + "time" + + "github.com/larksuite/cli/internal/envvars" + "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,8 +41,21 @@ func DetectProxyEnv() (key, value string) { return "", "" } +// proxyWarningOnce ensures proxy environment warnings are emitted at most once. var proxyWarningOnce sync.Once +// proxyPluginStatus reports the configured proxy plugin address, the extra +// trusted CA path (if any), and whether proxy plugin mode is enabled. It is +// indirected through a package variable so tests can simulate plugin-enabled +// mode without the process-global proxyplugin.Load() sync.Once cache. +var proxyPluginStatus = func() (addr, caPath string, enabled bool) { + cfg, err := proxyplugin.Load() + if err != nil || !cfg.Enabled() { + return "", "", false + } + return cfg.Proxy, cfg.CAPath, true +} + // redactProxyURL masks userinfo (username:password) in a proxy URL. // Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats. func redactProxyURL(raw string) string { @@ -60,6 +78,23 @@ func redactProxyURL(raw string) string { // are redacted. Safe to call multiple times; only the first call prints. func WarnIfProxied(w io.Writer) { proxyWarningOnce.Do(func() { + // Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see + // SharedTransport), so its warning and disable instructions take + // precedence. Emitting the env-proxy warning here would be misleading: + // it tells the user to set LARK_CLI_NO_PROXY=1, which does NOT disable + // the plugin proxy. + if addr, caPath, enabled := proxyPluginStatus(); enabled { + fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n", + redactProxyURL(addr), envvars.CliProxyEnable, proxyplugin.Path()) + if strings.TrimSpace(caPath) != "" { + // A custom CA means upstream TLS can be intercepted/inspected by + // the proxy (MITM). Surface it so the operator is aware traffic + // (including Bearer tokens) is decryptable on this host. + fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n", + caPath) + } + return + } if os.Getenv(EnvNoProxy) != "" { return } @@ -99,6 +134,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() } @@ -110,9 +150,31 @@ func SharedTransport() http.RoundTripper { // on the leak-free singleton path (internal/auth, internal/cmdutil // transport decorators) do not have to migrate. New code should prefer // SharedTransport and treat the base as an http.RoundTripper. +// +// Fail-closed invariant: proxyplugin always expresses its blocked/fail-closed +// transport as a concrete *http.Transport (see proxyplugin.failClosedTransport), +// so the assertion below preserves the block. The noProxyTransport() fallback is +// therefore only reached when no proxy plugin is configured and some external +// code replaced http.DefaultTransport with a non-*http.Transport — a case with +// no fail-closed intent, where a proxy-disabled transport is acceptable. func FallbackTransport() *http.Transport { if t, ok := SharedTransport().(*http.Transport); ok { return t } return noProxyTransport() } + +// NewHTTPClient returns an *http.Client whose Transport is the shared, +// proxy-plugin-aware base (see SharedTransport). Prefer this over a bare +// &http.Client{} for outbound requests: a bare client falls back to +// http.DefaultTransport and therefore silently bypasses proxy plugin mode +// (fixed proxy + trusted CA, or fail-closed), creating an audit blind spot. +// +// A zero timeout means no client-level timeout (callers relying on +// context deadlines pass 0). +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: SharedTransport(), + Timeout: timeout, + } +} diff --git a/internal/util/proxy_test.go b/internal/util/proxy_test.go index f7872096..ae23215e 100644 --- a/internal/util/proxy_test.go +++ b/internal/util/proxy_test.go @@ -6,11 +6,44 @@ package util import ( "bytes" "net/http" + "os" + "strings" "sync" "testing" + "time" + + "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 +61,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 +72,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 +90,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 +102,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 +122,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 +138,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 +162,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 +180,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 +197,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 +220,119 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) { } } +// TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is +// enabled, the warning describes the plugin proxy and the correct disable method +// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY +// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set. +func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + proxyWarningOnce = sync.Once{} + + old := proxyPluginStatus + proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true } + t.Cleanup(func() { proxyPluginStatus = old }) + + // Plugin mode overrides these; the warning must still be the plugin one. + t.Setenv("HTTPS_PROXY", "http://corp-proxy:8080") + t.Setenv(EnvNoProxy, "1") + + var buf bytes.Buffer + WarnIfProxied(&buf) + out := buf.String() + + if !strings.Contains(out, "127.0.0.1:3128") { + t.Errorf("warning should mention the plugin proxy address, got: %s", out) + } + if !strings.Contains(out, envvars.CliProxyEnable) { + t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out) + } + if strings.Contains(out, "Set "+EnvNoProxy+"=1") { + t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out) + } + // No custom CA configured -> no interception warning. + if strings.Contains(out, "custom CA") { + t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out) + } +} + +// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is +// trusted, the warning surfaces the TLS-interception capability (V3). +func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + proxyWarningOnce = sync.Once{} + + old := proxyPluginStatus + proxyPluginStatus = func() (string, string, bool) { + return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true + } + t.Cleanup(func() { proxyPluginStatus = old }) + + var buf bytes.Buffer + WarnIfProxied(&buf) + out := buf.String() + + if !strings.Contains(out, "custom CA") { + t.Errorf("warning should mention the custom CA, got: %s", out) + } + if !strings.Contains(out, "/etc/lark/extra_ca.pem") { + t.Errorf("warning should include the CA path, got: %s", out) + } + if !strings.Contains(out, "intercept") { + t.Errorf("warning should mention TLS interception, got: %s", out) + } +} + +// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware +// transport (instead of a bare client that bypasses proxy plugin mode). +func TestNewHTTPClient(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + t.Setenv(EnvNoProxy, "") + + c := NewHTTPClient(7 * time.Second) + if c.Transport == nil { + t.Fatal("NewHTTPClient transport is nil; want shared transport") + } + if c.Transport != SharedTransport() { + t.Errorf("NewHTTPClient transport = %v, want SharedTransport()", c.Transport) + } + if c.Timeout != 7*time.Second { + t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout) + } +} + +// TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin +// warning never leaks credentials embedded in the configured proxy address. +func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + proxyWarningOnce = sync.Once{} + + old := proxyPluginStatus + proxyPluginStatus = func() (string, string, bool) { return "http://user:s3cret@127.0.0.1:3128", "", true } + t.Cleanup(func() { proxyPluginStatus = old }) + + var buf bytes.Buffer + WarnIfProxied(&buf) + out := buf.String() + + if strings.Contains(out, "s3cret") { + t.Errorf("plugin warning leaked password, got: %s", out) + } + if strings.Contains(out, "user:") { + t.Errorf("plugin warning leaked username, got: %s", out) + } + if !strings.Contains(out, "***@127.0.0.1:3128") { + t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out) + } +} + +// 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 +355,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")