Skip to content
Open
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: 4 additions & 0 deletions internal/envvars/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
240 changes: 240 additions & 0 deletions internal/proxyplugin/config.go
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 73 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L72-L73

Added lines #L72 - L73 were not covered by tests
}

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

Check warning on line 89 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L88-L89

Added lines #L88 - L89 were not covered by tests
}
b, err := vfs.ReadFile(p)
if err != nil {
loadErr = fmt.Errorf("failed to read proxy plugin config %q: %w", p, err)
return

Check warning on line 94 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L93-L94

Added lines #L93 - L94 were not covered by tests
}
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

Check warning on line 129 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L129

Added line #L129 was not covered by tests
}
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

Check warning on line 139 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L139

Added line #L139 was not covered by tests
}
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

Check warning on line 157 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L157

Added line #L157 was not covered by tests
}
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

Check warning on line 166 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L165-L166

Added lines #L165 - L166 were not covered by tests
}
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)

Check warning on line 168 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L168

Added line #L168 was not covered by tests
}

// 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)

Check warning on line 175 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L175

Added line #L175 was not covered by tests
}
redacted := redactProxyURL(raw)
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliProxyAddress, redacted, err)

Check warning on line 180 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L180

Added line #L180 was not covered by tests
}
if u.Scheme != "http" {
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliProxyAddress, redacted)

Check warning on line 183 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L183

Added line #L183 was not covered by tests
}
if u.Host == "" {
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliProxyAddress, redacted)

Check warning on line 186 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L186

Added line #L186 was not covered by tests
}
// 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()

Check warning on line 214 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L213-L214

Added lines #L213 - L214 were not covered by tests
}
// Fallback: handle "user:pass@proxy:8080"
if at := strings.LastIndex(raw, "@"); at > 0 {
return "***@" + raw[at+1:]

Check warning on line 218 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L218

Added line #L218 was not covered by tests
}
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)

Check warning on line 227 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L227

Added line #L227 was not covered by tests
}
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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return nil, err

Check warning on line 237 in internal/proxyplugin/config.go

View check run for this annotation

Codecov / codecov/patch

internal/proxyplugin/config.go#L237

Added line #L237 was not covered by tests
}
return t, nil
}
Loading
Loading