Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/auth/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions internal/cmdutil/factory_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

var transport http.RoundTripper = util.NewBaseTransport()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}

Expand All @@ -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{
Expand Down
8 changes: 5 additions & 3 deletions internal/cmdutil/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion internal/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"time"

"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
)

Expand Down Expand Up @@ -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.
Expand Down
102 changes: 102 additions & 0 deletions internal/util/proxy.go
Original file line number Diff line number Diff line change
@@ -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()
}
190 changes: 190 additions & 0 deletions internal/util/proxy_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading