Files
larksuite-cli/internal/proxyplugin/config.go
JackZhao10086 f65712cacf 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>
2026-06-02 10:57:02 +08:00

258 lines
7.9 KiB
Go

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