diff --git a/internal/auth/transport.go b/internal/auth/transport.go index 2a1670499..9c5d29a77 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -11,6 +11,8 @@ import ( "net/http" "net/url" "strings" + + "github.com/larksuite/cli/internal/util" ) // SecurityPolicyTransport is an http.RoundTripper that intercepts all responses @@ -23,7 +25,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return http.DefaultTransport + return util.FallbackTransport() } // RoundTrip implements http.RoundTripper. diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index adfbb582f..769fbc409 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/util" ) // NewDefault creates a production Factory with cached closures. @@ -73,7 +74,9 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { func cachedHttpClientFunc() func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - var transport = http.DefaultTransport + util.WarnIfProxied(os.Stderr) + + var transport http.RoundTripper = util.NewBaseTransport() transport = &RetryTransport{Base: transport} transport = &SecurityHeaderTransport{Base: transport} @@ -98,7 +101,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithHeaders(BaseSecurityHeaders()), } // Build SDK transport chain - var sdkTransport = http.DefaultTransport + util.WarnIfProxied(os.Stderr) + var sdkTransport http.RoundTripper = util.NewBaseTransport() sdkTransport = &UserAgentTransport{Base: sdkTransport} sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport} opts = append(opts, lark.WithHttpClient(&http.Client{ diff --git a/internal/cmdutil/transport.go b/internal/cmdutil/transport.go index bbdc0d3e3..366fc7ca3 100644 --- a/internal/cmdutil/transport.go +++ b/internal/cmdutil/transport.go @@ -6,6 +6,8 @@ package cmdutil import ( "net/http" "time" + + "github.com/larksuite/cli/internal/util" ) // RetryTransport is an http.RoundTripper that retries on 5xx responses @@ -20,7 +22,7 @@ func (t *RetryTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return http.DefaultTransport + return util.FallbackTransport() } func (t *RetryTransport) delay() time.Duration { @@ -65,7 +67,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error if t.Base != nil { return t.Base.RoundTrip(req) } - return http.DefaultTransport.RoundTrip(req) + return util.FallbackTransport().RoundTrip(req) } // SecurityHeaderTransport is an http.RoundTripper that injects CLI security @@ -78,7 +80,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return http.DefaultTransport + return util.FallbackTransport() } // RoundTrip implements http.RoundTripper. diff --git a/internal/update/update.go b/internal/update/update.go index 68e0a265c..c051ec49a 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -17,6 +17,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" ) @@ -57,7 +58,10 @@ func httpClient() *http.Client { if DefaultClient != nil { return DefaultClient } - return &http.Client{Timeout: fetchTimeout} + return &http.Client{ + Timeout: fetchTimeout, + Transport: util.NewBaseTransport(), + } } // updateState is persisted to disk for caching. diff --git a/internal/util/proxy.go b/internal/util/proxy.go new file mode 100644 index 000000000..64308da85 --- /dev/null +++ b/internal/util/proxy.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import ( + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" +) + +const ( + // EnvNoProxy disables automatic proxy support when set to any non-empty value. + EnvNoProxy = "LARK_CLI_NO_PROXY" +) + +// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads. +var proxyEnvKeys = []string{ + "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", + "ALL_PROXY", "all_proxy", +} + +// DetectProxyEnv returns the first proxy-related environment variable that is set, +// or empty strings if none are configured. +func DetectProxyEnv() (key, value string) { + for _, k := range proxyEnvKeys { + if v := os.Getenv(k); v != "" { + return k, v + } + } + return "", "" +} + +var proxyWarningOnce sync.Once + +// 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 { + // Try standard url.Parse first (works when scheme is present) + u, err := url.Parse(raw) + if err == nil && u.User != nil { + return u.Scheme + "://***@" + u.Host + u.RequestURI() + } + + // Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080") + if at := strings.LastIndex(raw, "@"); at > 0 { + return "***@" + raw[at+1:] + } + + return raw +} + +// WarnIfProxied prints a one-time warning to w when a proxy environment variable +// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials +// are redacted. Safe to call multiple times; only the first call prints. +func WarnIfProxied(w io.Writer) { + proxyWarningOnce.Do(func() { + if os.Getenv(EnvNoProxy) != "" { + return + } + key, val := DetectProxyEnv() + if key == "" { + return + } + fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n", + key, redactProxyURL(val), EnvNoProxy) + }) +} + +// NewBaseTransport creates an *http.Transport cloned from http.DefaultTransport. +// If LARK_CLI_NO_PROXY is set, proxy support is disabled. +// Each call returns a new instance; use FallbackTransport for a shared singleton. +func NewBaseTransport() *http.Transport { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + t := def.Clone() + if os.Getenv(EnvNoProxy) != "" { + t.Proxy = nil + } + return t +} + +// fallbackTransport is a lazily-initialized singleton used by transport +// decorators when their Base field is nil, preserving connection pooling. +var fallbackTransport = sync.OnceValue(func() *http.Transport { + return NewBaseTransport() +}) + +// FallbackTransport returns a shared *http.Transport singleton suitable for +// use as a fallback when a transport decorator's Base is nil. +// Unlike NewBaseTransport (which clones per call), this reuses a single +// instance so that TCP connections and TLS sessions are pooled. +func FallbackTransport() *http.Transport { + return fallbackTransport() +} diff --git a/internal/util/proxy_test.go b/internal/util/proxy_test.go new file mode 100644 index 000000000..daf1b7ab4 --- /dev/null +++ b/internal/util/proxy_test.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import ( + "bytes" + "net/http" + "sync" + "testing" +) + +func TestDetectProxyEnv(t *testing.T) { + // Clear all proxy env vars first + for _, k := range proxyEnvKeys { + t.Setenv(k, "") + } + + key, val := DetectProxyEnv() + if key != "" || val != "" { + t.Errorf("expected no proxy, got %s=%s", key, val) + } + + t.Setenv("HTTPS_PROXY", "http://proxy:8888") + key, val = DetectProxyEnv() + if key != "HTTPS_PROXY" || val != "http://proxy:8888" { + t.Errorf("expected HTTPS_PROXY=http://proxy:8888, got %s=%s", key, val) + } +} + +func TestNewBaseTransport_Default(t *testing.T) { + t.Setenv(EnvNoProxy, "") + tr := NewBaseTransport() + if tr.Proxy == nil { + t.Error("expected proxy func to be set when LARK_CLI_NO_PROXY is not set") + } +} + +func TestNewBaseTransport_NoProxy(t *testing.T) { + t.Setenv(EnvNoProxy, "1") + tr := NewBaseTransport() + if tr.Proxy != nil { + t.Error("expected proxy func to be nil when LARK_CLI_NO_PROXY=1") + } +} + +func TestWarnIfProxied_WithProxy(t *testing.T) { + // Reset the once guard for this test + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + out := buf.String() + if out == "" { + t.Error("expected warning output when proxy is set") + } + if !bytes.Contains([]byte(out), []byte("HTTPS_PROXY")) { + t.Errorf("warning should mention HTTPS_PROXY, got: %s", out) + } + if !bytes.Contains([]byte(out), []byte(EnvNoProxy)) { + t.Errorf("warning should mention %s, got: %s", EnvNoProxy, out) + } +} + +func TestWarnIfProxied_WithoutProxy(t *testing.T) { + proxyWarningOnce = sync.Once{} + + for _, k := range proxyEnvKeys { + t.Setenv(k, "") + } + + var buf bytes.Buffer + WarnIfProxied(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no output when no proxy is set, got: %s", buf.String()) + } +} + +func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://proxy:8080") + t.Setenv(EnvNoProxy, "1") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no warning when proxy is disabled, got: %s", buf.String()) + } +} + +func TestWarnIfProxied_OnlyOnce(t *testing.T) { + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTP_PROXY", "http://proxy:1234") + + var buf bytes.Buffer + WarnIfProxied(&buf) + first := buf.String() + + WarnIfProxied(&buf) + second := buf.String() + + if first == "" { + t.Error("expected warning on first call") + } + if second != first { + t.Error("expected no additional output on second call") + } +} + +func TestRedactProxyURL(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"http://proxy:8080", "http://proxy:8080"}, + {"http://user:pass@proxy:8080", "http://***@proxy:8080/"}, + {"http://user:p%40ss@proxy:8080/path", "http://***@proxy:8080/path"}, + {"http://user@proxy:8080", "http://***@proxy:8080/"}, + {"socks5://admin:secret@10.0.0.1:1080", "socks5://***@10.0.0.1:1080/"}, + {"user:pass@proxy:8080", "***@proxy:8080"}, + {"admin:s3cret@10.0.0.1:3128", "***@10.0.0.1:3128"}, + {"not-a-url", "not-a-url"}, + {"", ""}, + } + for _, tt := range tests { + got := redactProxyURL(tt.input) + if got != tt.want { + t.Errorf("redactProxyURL(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestWarnIfProxied_RedactsCredentials(t *testing.T) { + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + out := buf.String() + if bytes.Contains([]byte(out), []byte("s3cret")) { + t.Errorf("warning should not contain proxy password, got: %s", out) + } + if bytes.Contains([]byte(out), []byte("admin")) { + t.Errorf("warning should not contain proxy username, got: %s", out) + } + if !bytes.Contains([]byte(out), []byte("***@proxy:8080")) { + t.Errorf("warning should contain redacted proxy URL, got: %s", out) + } +} + +func TestNewBaseTransport_IsHTTPTransport(t *testing.T) { + t.Setenv(EnvNoProxy, "") + tr := NewBaseTransport() + + // Should be a valid *http.Transport that can be used + var rt http.RoundTripper = tr + _ = rt + + // Verify it's not the same pointer as DefaultTransport (should be a clone) + if tr == http.DefaultTransport { + t.Error("NewBaseTransport should return a clone, not DefaultTransport itself") + } +} + +func TestNewBaseTransport_RespectsNoProxyEnv(t *testing.T) { + // Simulate: user sets both system proxy and our disable flag + t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") + t.Setenv(EnvNoProxy, "1") + + tr := NewBaseTransport() + if tr.Proxy != nil { + t.Error("LARK_CLI_NO_PROXY should override system proxy settings") + } + + // Clean up and verify proxy is restored + t.Setenv(EnvNoProxy, "") + tr2 := NewBaseTransport() + if tr2.Proxy == nil { + t.Error("proxy should be enabled when LARK_CLI_NO_PROXY is unset") + } +}