From e14bf476d1951353581877776449247f372824d0 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Thu, 23 Apr 2026 17:06:53 +0800 Subject: [PATCH 1/4] feat: add security plugin for proxy --- internal/envvars/envvars.go | 4 + internal/proxyplugin/README.md | 90 ++++++++++ internal/proxyplugin/README.zh-CN.md | 86 +++++++++ internal/proxyplugin/config.go | 240 +++++++++++++++++++++++++ internal/proxyplugin/config_test.go | 237 ++++++++++++++++++++++++ internal/proxyplugin/tls_ca.go | 53 ++++++ internal/proxyplugin/tls_ca_test.go | 153 ++++++++++++++++ internal/proxyplugin/transport.go | 61 +++++++ internal/proxyplugin/transport_test.go | 61 +++++++ internal/util/proxy.go | 9 + internal/util/proxy_test.go | 64 +++++++ 11 files changed, 1058 insertions(+) create mode 100644 internal/proxyplugin/README.md create mode 100644 internal/proxyplugin/README.zh-CN.md create mode 100644 internal/proxyplugin/config.go create mode 100644 internal/proxyplugin/config_test.go create mode 100644 internal/proxyplugin/tls_ca.go create mode 100644 internal/proxyplugin/tls_ca_test.go create mode 100644 internal/proxyplugin/transport.go create mode 100644 internal/proxyplugin/transport_test.go 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/README.md b/internal/proxyplugin/README.md new file mode 100644 index 000000000..f4363f2af --- /dev/null +++ b/internal/proxyplugin/README.md @@ -0,0 +1,90 @@ +# proxyplugin Usage Guide + +Chinese version: see `README.zh-CN.md`. + +`proxyplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S) +requests to go through a local security proxy and can optionally trust an +additional CA certificate bundle. + +It supports two configuration methods: + +1. `proxy_config.json` +2. `LARKSUITE_CLI_PROXY_ENABLE`, `LARKSUITE_CLI_PROXY_ADDRESS`, and `LARKSUITE_CLI_CA_PATH` environment variables + +## Config File Location + +Default config file path: + +```text +~/.lark-cli/proxy_config.json +``` + +If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes: + +```text +$LARKSUITE_CLI_CONFIG_DIR/proxy_config.json +``` + +## Option 1: Config File + +Put the following content into `proxy_config.json`: + +```json +{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "/absolute/path/to/proxy-ca.pem" +} +``` + +Field descriptions: + +- `LARKSUITE_CLI_PROXY_ENABLE`: Enables proxyplugin. Boolean values are supported. +- `LARKSUITE_CLI_PROXY_ADDRESS`: Local HTTP proxy address. It must be `http://127.0.0.1:`. +- `LARKSUITE_CLI_CA_PATH`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed. + +## Option 2: Environment Variables + +You can also enable proxyplugin directly with environment variables without +creating `proxy_config.json`: + +```bash +export LARKSUITE_CLI_PROXY_ENABLE=true +export LARKSUITE_CLI_PROXY_ADDRESS=http://127.0.0.1:3128 +export LARKSUITE_CLI_CA_PATH=/absolute/path/to/proxy-ca.pem +``` + +## Precedence + +The following environment variables override the corresponding fields in +`proxy_config.json` when they are present: + +- `LARKSUITE_CLI_PROXY_ENABLE` +- `LARKSUITE_CLI_PROXY_ADDRESS` +- `LARKSUITE_CLI_CA_PATH` + +This means: + +- Put stable defaults in `proxy_config.json`. +- Use environment variables for temporary overrides. +- proxy-related environment variables can work even without a config file. + +## Constraints + +- `LARKSUITE_CLI_PROXY_ADDRESS` must use the `http` scheme only. +- The host of `LARKSUITE_CLI_PROXY_ADDRESS` must be `127.0.0.1`. +- `LARKSUITE_CLI_PROXY_ADDRESS` must not contain a path. +- `LARKSUITE_CLI_CA_PATH` must be an absolute path to a PEM file. +- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`. + +## Recommendations + +For long-term stable setup, prefer `proxy_config.json`: + +- Good for developer machines or controlled environments. +- Avoids repeatedly injecting environment variables into the shell. + +For temporary debugging, prefer environment variables: + +- Good for switching proxy or CA for just one session. +- No need to modify files on disk. diff --git a/internal/proxyplugin/README.zh-CN.md b/internal/proxyplugin/README.zh-CN.md new file mode 100644 index 000000000..4a42a4fbc --- /dev/null +++ b/internal/proxyplugin/README.zh-CN.md @@ -0,0 +1,86 @@ +# proxyplugin 使用说明 + +English version: see `README.md`. + +`proxyplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。 + +支持两种配置方式: + +1. `proxy_config.json` +2. `LARKSUITE_CLI_PROXY_ENABLE`、`LARKSUITE_CLI_PROXY_ADDRESS` 和 `LARKSUITE_CLI_CA_PATH` 环境变量 + +## 配置文件位置 + +默认配置文件路径: + +```text +~/.lark-cli/proxy_config.json +``` + +如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为: + +```text +$LARKSUITE_CLI_CONFIG_DIR/proxy_config.json +``` + +## 方式一:使用配置文件 + +在 `proxy_config.json` 中写入: + +```json +{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", + "LARKSUITE_CLI_CA_PATH": "/absolute/path/to/proxy-ca.pem" +} +``` + +字段说明: + +- `LARKSUITE_CLI_PROXY_ENABLE`: 是否启用 proxyplugin,支持布尔值。 +- `LARKSUITE_CLI_PROXY_ADDRESS`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:`。 +- `LARKSUITE_CLI_CA_PATH`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。 + +## 方式二:使用环境变量 + +也可以不写 `proxy_config.json`,直接通过环境变量启用: + +```bash +export LARKSUITE_CLI_PROXY_ENABLE=true +export LARKSUITE_CLI_PROXY_ADDRESS=http://127.0.0.1:3128 +export LARKSUITE_CLI_CA_PATH=/absolute/path/to/proxy-ca.pem +``` + +## 配置优先级 + +以下环境变量存在时,会覆盖 `proxy_config.json` 中对应字段: + +- `LARKSUITE_CLI_PROXY_ENABLE` +- `LARKSUITE_CLI_PROXY_ADDRESS` +- `LARKSUITE_CLI_CA_PATH` + +也就是说: + +- 你可以把默认值写进 `proxy_config.json`。 +- 再用环境变量做临时覆盖。 +- 如果没有配置文件,但设置了 代理相关环境变量,也可以正常工作。 + +## 参数约束 + +- `LARKSUITE_CLI_PROXY_ADDRESS` 只允许 `http` 协议。 +- `LARKSUITE_CLI_PROXY_ADDRESS` 的 host 必须是 `127.0.0.1`。 +- `LARKSUITE_CLI_PROXY_ADDRESS` 不能带路径。 +- `LARKSUITE_CLI_CA_PATH` 必须是 PEM 文件的绝对路径。 +- 布尔值支持 `true/false`、`1/0`、`on/off`、`yes/no`、`y/n`。 + +## 推荐用法 + +长期固定配置建议使用 `proxy_config.json`: + +- 适合开发机或受控环境的稳定配置。 +- 避免在 shell 中反复注入环境变量。 + +临时调试建议使用环境变量: + +- 适合本次会话临时切换代理或证书。 +- 不需要修改磁盘上的配置文件。 diff --git a/internal/proxyplugin/config.go b/internal/proxyplugin/config.go new file mode 100644 index 000000000..c0b5e75ee --- /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 != "" && 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..4502e15ba --- /dev/null +++ b/internal/proxyplugin/config_test.go @@ -0,0 +1,237 @@ +// 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, 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: "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..e63177e8a --- /dev/null +++ b/internal/proxyplugin/tls_ca.go @@ -0,0 +1,53 @@ +// 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/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) + } + pemBytes, err := vfs.ReadFile(caPath) + 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..b16efc5f8 --- /dev/null +++ b/internal/proxyplugin/tls_ca_test.go @@ -0,0 +1,153 @@ +// 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" + "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 read errors for missing PEM bundles. +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(), "failed to read") { + t.Fatalf("applyExtraRootCA() error = %v, want read 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_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..810cbd538 --- /dev/null +++ b/internal/proxyplugin/transport.go @@ -0,0 +1,61 @@ +// 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) + +func buildProxyPluginTransport() *http.Transport { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + + cfg, err := Load() + if err != nil || 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 { + // Fail closed: if the config file exists but is malformed/unreadable, + // do not silently fall back to direct egress. + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return http.DefaultTransport, true + } + return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err)), true + } + if cfg == nil || !cfg.Enabled() { + return nil, false + } + return proxyPluginTransport(), true +} + +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..cf6d6d9f6 --- /dev/null +++ b/internal/proxyplugin/transport_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package proxyplugin + +import ( + "net/http" + "net/url" + "sync" + "testing" +) + +func resetProxyPluginState() { + loadOnce = sync.Once{} + loadCfg = nil + loadErr = nil + proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) +} + +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) + } +} 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") From 1f372c13e029f76b435da5c0d5fdfaad18eac689 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Sat, 30 May 2026 13:50:18 +0800 Subject: [PATCH 2/4] docs: remove outdated proxyplugin README files --- internal/proxyplugin/README.md | 90 ---------------------------- internal/proxyplugin/README.zh-CN.md | 86 -------------------------- 2 files changed, 176 deletions(-) delete mode 100644 internal/proxyplugin/README.md delete mode 100644 internal/proxyplugin/README.zh-CN.md diff --git a/internal/proxyplugin/README.md b/internal/proxyplugin/README.md deleted file mode 100644 index f4363f2af..000000000 --- a/internal/proxyplugin/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# proxyplugin Usage Guide - -Chinese version: see `README.zh-CN.md`. - -`proxyplugin` enables a secure proxy mode for the CLI. It forces outbound HTTP(S) -requests to go through a local security proxy and can optionally trust an -additional CA certificate bundle. - -It supports two configuration methods: - -1. `proxy_config.json` -2. `LARKSUITE_CLI_PROXY_ENABLE`, `LARKSUITE_CLI_PROXY_ADDRESS`, and `LARKSUITE_CLI_CA_PATH` environment variables - -## Config File Location - -Default config file path: - -```text -~/.lark-cli/proxy_config.json -``` - -If `LARKSUITE_CLI_CONFIG_DIR` is set, the path becomes: - -```text -$LARKSUITE_CLI_CONFIG_DIR/proxy_config.json -``` - -## Option 1: Config File - -Put the following content into `proxy_config.json`: - -```json -{ - "LARKSUITE_CLI_PROXY_ENABLE": true, - "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", - "LARKSUITE_CLI_CA_PATH": "/absolute/path/to/proxy-ca.pem" -} -``` - -Field descriptions: - -- `LARKSUITE_CLI_PROXY_ENABLE`: Enables proxyplugin. Boolean values are supported. -- `LARKSUITE_CLI_PROXY_ADDRESS`: Local HTTP proxy address. It must be `http://127.0.0.1:`. -- `LARKSUITE_CLI_CA_PATH`: Absolute path to an extra trusted root CA PEM file. Leave empty if not needed. - -## Option 2: Environment Variables - -You can also enable proxyplugin directly with environment variables without -creating `proxy_config.json`: - -```bash -export LARKSUITE_CLI_PROXY_ENABLE=true -export LARKSUITE_CLI_PROXY_ADDRESS=http://127.0.0.1:3128 -export LARKSUITE_CLI_CA_PATH=/absolute/path/to/proxy-ca.pem -``` - -## Precedence - -The following environment variables override the corresponding fields in -`proxy_config.json` when they are present: - -- `LARKSUITE_CLI_PROXY_ENABLE` -- `LARKSUITE_CLI_PROXY_ADDRESS` -- `LARKSUITE_CLI_CA_PATH` - -This means: - -- Put stable defaults in `proxy_config.json`. -- Use environment variables for temporary overrides. -- proxy-related environment variables can work even without a config file. - -## Constraints - -- `LARKSUITE_CLI_PROXY_ADDRESS` must use the `http` scheme only. -- The host of `LARKSUITE_CLI_PROXY_ADDRESS` must be `127.0.0.1`. -- `LARKSUITE_CLI_PROXY_ADDRESS` must not contain a path. -- `LARKSUITE_CLI_CA_PATH` must be an absolute path to a PEM file. -- Boolean values support `true/false`, `1/0`, `on/off`, `yes/no`, and `y/n`. - -## Recommendations - -For long-term stable setup, prefer `proxy_config.json`: - -- Good for developer machines or controlled environments. -- Avoids repeatedly injecting environment variables into the shell. - -For temporary debugging, prefer environment variables: - -- Good for switching proxy or CA for just one session. -- No need to modify files on disk. diff --git a/internal/proxyplugin/README.zh-CN.md b/internal/proxyplugin/README.zh-CN.md deleted file mode 100644 index 4a42a4fbc..000000000 --- a/internal/proxyplugin/README.zh-CN.md +++ /dev/null @@ -1,86 +0,0 @@ -# proxyplugin 使用说明 - -English version: see `README.md`. - -`proxyplugin` 用于开启安全代理模式,让 CLI 的 HTTP(S) 请求固定走本地安全代理,并按需信任额外 CA 证书。 - -支持两种配置方式: - -1. `proxy_config.json` -2. `LARKSUITE_CLI_PROXY_ENABLE`、`LARKSUITE_CLI_PROXY_ADDRESS` 和 `LARKSUITE_CLI_CA_PATH` 环境变量 - -## 配置文件位置 - -默认配置文件路径: - -```text -~/.lark-cli/proxy_config.json -``` - -如果设置了 `LARKSUITE_CLI_CONFIG_DIR`,则配置文件路径变为: - -```text -$LARKSUITE_CLI_CONFIG_DIR/proxy_config.json -``` - -## 方式一:使用配置文件 - -在 `proxy_config.json` 中写入: - -```json -{ - "LARKSUITE_CLI_PROXY_ENABLE": true, - "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128", - "LARKSUITE_CLI_CA_PATH": "/absolute/path/to/proxy-ca.pem" -} -``` - -字段说明: - -- `LARKSUITE_CLI_PROXY_ENABLE`: 是否启用 proxyplugin,支持布尔值。 -- `LARKSUITE_CLI_PROXY_ADDRESS`: 本地 HTTP 代理地址,必须是 `http://127.0.0.1:`。 -- `LARKSUITE_CLI_CA_PATH`: 额外信任的根证书 PEM 文件绝对路径;不需要时可留空。 - -## 方式二:使用环境变量 - -也可以不写 `proxy_config.json`,直接通过环境变量启用: - -```bash -export LARKSUITE_CLI_PROXY_ENABLE=true -export LARKSUITE_CLI_PROXY_ADDRESS=http://127.0.0.1:3128 -export LARKSUITE_CLI_CA_PATH=/absolute/path/to/proxy-ca.pem -``` - -## 配置优先级 - -以下环境变量存在时,会覆盖 `proxy_config.json` 中对应字段: - -- `LARKSUITE_CLI_PROXY_ENABLE` -- `LARKSUITE_CLI_PROXY_ADDRESS` -- `LARKSUITE_CLI_CA_PATH` - -也就是说: - -- 你可以把默认值写进 `proxy_config.json`。 -- 再用环境变量做临时覆盖。 -- 如果没有配置文件,但设置了 代理相关环境变量,也可以正常工作。 - -## 参数约束 - -- `LARKSUITE_CLI_PROXY_ADDRESS` 只允许 `http` 协议。 -- `LARKSUITE_CLI_PROXY_ADDRESS` 的 host 必须是 `127.0.0.1`。 -- `LARKSUITE_CLI_PROXY_ADDRESS` 不能带路径。 -- `LARKSUITE_CLI_CA_PATH` 必须是 PEM 文件的绝对路径。 -- 布尔值支持 `true/false`、`1/0`、`on/off`、`yes/no`、`y/n`。 - -## 推荐用法 - -长期固定配置建议使用 `proxy_config.json`: - -- 适合开发机或受控环境的稳定配置。 -- 避免在 shell 中反复注入环境变量。 - -临时调试建议使用环境变量: - -- 适合本次会话临时切换代理或证书。 -- 不需要修改磁盘上的配置文件。 From cf095f62266434ac98c1f398b784d0f122226f20 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Sat, 30 May 2026 14:05:39 +0800 Subject: [PATCH 3/4] refactor(proxyplugin): tighten proxy URL validation and add security checks --- internal/proxyplugin/config.go | 2 +- internal/proxyplugin/config_test.go | 7 ++- internal/proxyplugin/tls_ca.go | 11 ++++- internal/proxyplugin/tls_ca_test.go | 26 +++++++++-- internal/proxyplugin/transport.go | 14 ++++-- internal/proxyplugin/transport_test.go | 61 ++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 9 deletions(-) diff --git a/internal/proxyplugin/config.go b/internal/proxyplugin/config.go index c0b5e75ee..1731f7296 100644 --- a/internal/proxyplugin/config.go +++ b/internal/proxyplugin/config.go @@ -193,7 +193,7 @@ func (c *Config) proxyURL() (*url.URL, error) { if u.Port() == "" { return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliProxyAddress, redacted) } - if u.Path != "" && u.Path != "/" { + if u.Path != "" { return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliProxyAddress, redacted) } if u.RawQuery != "" { diff --git a/internal/proxyplugin/config_test.go b/internal/proxyplugin/config_test.go index 4502e15ba..06b2e3bc9 100644 --- a/internal/proxyplugin/config_test.go +++ b/internal/proxyplugin/config_test.go @@ -137,7 +137,7 @@ func TestLoad_RejectsNonLoopbackProxy(t *testing.T) { } // TestConfig_ProxyURLRejectsUnsupportedParts verifies the configured proxy validator -// rejects URLs with missing ports, queries, and fragments. +// rejects URLs with missing ports, paths, queries, and fragments. func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) { cases := []struct { name string @@ -149,6 +149,11 @@ func TestConfig_ProxyURLRejectsUnsupportedParts(t *testing.T) { 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", diff --git a/internal/proxyplugin/tls_ca.go b/internal/proxyplugin/tls_ca.go index e63177e8a..75b7665d1 100644 --- a/internal/proxyplugin/tls_ca.go +++ b/internal/proxyplugin/tls_ca.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/internal/binding" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/vfs" ) @@ -25,7 +26,15 @@ func applyExtraRootCA(t *http.Transport, caPath string) error { if !filepath.IsAbs(caPath) { return fmt.Errorf("invalid %s %q: must be an absolute path to a PEM file", envvars.CliCAPath, caPath) } - pemBytes, err := vfs.ReadFile(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) } diff --git a/internal/proxyplugin/tls_ca_test.go b/internal/proxyplugin/tls_ca_test.go index b16efc5f8..31e25c23b 100644 --- a/internal/proxyplugin/tls_ca_test.go +++ b/internal/proxyplugin/tls_ca_test.go @@ -12,6 +12,7 @@ import ( "encoding/pem" "math/big" "net/http" + "os" "path/filepath" "strings" "testing" @@ -77,13 +78,13 @@ func TestApplyExtraRootCA_RejectsRelativePath(t *testing.T) { } } -// TestApplyExtraRootCA_RejectsMissingFile verifies read errors for missing PEM bundles. +// 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(), "failed to read") { - t.Fatalf("applyExtraRootCA() error = %v, want read error", err) + if err == nil || !strings.Contains(err.Error(), "unsafe") { + t.Fatalf("applyExtraRootCA() error = %v, want unsafe path error", err) } } @@ -99,6 +100,25 @@ func TestApplyExtraRootCA_RejectsInvalidPEM(t *testing.T) { } } +// 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") diff --git a/internal/proxyplugin/transport.go b/internal/proxyplugin/transport.go index 810cbd538..2683c4caa 100644 --- a/internal/proxyplugin/transport.go +++ b/internal/proxyplugin/transport.go @@ -14,10 +14,10 @@ import ( // custom root CA), lazily built on first use when proxy plugin mode is enabled. var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) -func buildProxyPluginTransport() *http.Transport { +func buildProxyPluginTransport() http.RoundTripper { def, ok := http.DefaultTransport.(*http.Transport) if !ok { - return &http.Transport{} + return blockedRoundTripper{err: fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport)} } cfg, err := Load() @@ -42,7 +42,7 @@ func SharedTransport() (http.RoundTripper, bool) { // do not silently fall back to direct egress. def, ok := http.DefaultTransport.(*http.Transport) if !ok { - return http.DefaultTransport, true + return blockedRoundTripper{err: fmt.Errorf("proxy plugin config is invalid and http.DefaultTransport is %T, want *http.Transport: %w", http.DefaultTransport, err)}, true } return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err)), true } @@ -52,6 +52,14 @@ func SharedTransport() (http.RoundTripper, bool) { 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) { diff --git a/internal/proxyplugin/transport_test.go b/internal/proxyplugin/transport_test.go index cf6d6d9f6..f01009790 100644 --- a/internal/proxyplugin/transport_test.go +++ b/internal/proxyplugin/transport_test.go @@ -4,8 +4,10 @@ package proxyplugin import ( + "io" "net/http" "net/url" + "strings" "sync" "testing" ) @@ -59,3 +61,62 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) { 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 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 + } +} From 6d161b025358870016b7d1422342eaa12121cf10 Mon Sep 17 00:00:00 2001 From: zhaojunchang Date: Sat, 30 May 2026 14:46:42 +0800 Subject: [PATCH 4/4] refactor(proxyplugin): cache blocked transport and clean up error handling --- internal/proxyplugin/transport.go | 28 +++++++++++++----- internal/proxyplugin/transport_test.go | 41 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/internal/proxyplugin/transport.go b/internal/proxyplugin/transport.go index 2683c4caa..d85965571 100644 --- a/internal/proxyplugin/transport.go +++ b/internal/proxyplugin/transport.go @@ -14,6 +14,19 @@ import ( // 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 { @@ -21,7 +34,12 @@ func buildProxyPluginTransport() http.RoundTripper { } cfg, err := Load() - if err != nil || cfg == nil || !cfg.Enabled() { + 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) @@ -38,13 +56,7 @@ func buildProxyPluginTransport() http.RoundTripper { func SharedTransport() (http.RoundTripper, bool) { cfg, err := Load() if err != nil { - // Fail closed: if the config file exists but is malformed/unreadable, - // do not silently fall back to direct egress. - 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, err)}, true - } - return blockedTransport(def, fmt.Errorf("proxy plugin config is invalid: %w", err)), true + return cachedBlockedTransport(), true } if cfg == nil || !cfg.Enabled() { return nil, false diff --git a/internal/proxyplugin/transport_test.go b/internal/proxyplugin/transport_test.go index f01009790..c00ad2e38 100644 --- a/internal/proxyplugin/transport_test.go +++ b/internal/proxyplugin/transport_test.go @@ -17,6 +17,7 @@ func resetProxyPluginState() { loadCfg = nil loadErr = nil proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) + cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) } func TestSharedTransport_NotConfigured(t *testing.T) { @@ -87,6 +88,46 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test } } +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)