mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: add proxy plugin mode for CLI HTTP transport (#1181)
* feat: add security plugin for proxy * docs: remove outdated proxyplugin README files * refactor(proxyplugin): tighten proxy URL validation and add security checks * refactor(proxyplugin): cache blocked transport and clean up error handling * fix(proxyplugin): fix CR issues for Security hardening --------- Co-authored-by: AlbertSun <sunxingjian@bytedance.com>
This commit is contained in:
@@ -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"
|
||||
)
|
||||
|
||||
257
internal/proxyplugin/config.go
Normal file
257
internal/proxyplugin/config.go
Normal file
@@ -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
|
||||
}
|
||||
372
internal/proxyplugin/config_test.go
Normal file
372
internal/proxyplugin/config_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
68
internal/proxyplugin/tls_ca.go
Normal file
68
internal/proxyplugin/tls_ca.go
Normal file
@@ -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
|
||||
}
|
||||
173
internal/proxyplugin/tls_ca_test.go
Normal file
173
internal/proxyplugin/tls_ca_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
90
internal/proxyplugin/transport.go
Normal file
90
internal/proxyplugin/transport.go
Normal file
@@ -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
|
||||
}
|
||||
195
internal/proxyplugin/transport_test.go
Normal file
195
internal/proxyplugin/transport_test.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user