-
Notifications
You must be signed in to change notification settings - Fork 896
feat: add proxy plugin mode for CLI HTTP transport #1181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JackZhao10086
wants to merge
4
commits into
main
Choose a base branch
from
feat/sec_mode
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
e14bf47
feat: add security plugin for proxy
JackZhao10086 1f372c1
docs: remove outdated proxyplugin README files
JackZhao10086 cf095f6
refactor(proxyplugin): tighten proxy URL validation and add security …
JackZhao10086 6d161b0
refactor(proxyplugin): cache blocked transport and clean up error han…
JackZhao10086 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| b, err := vfs.ReadFile(p) | ||
| 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 { | ||
| return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliProxyAddress, redacted, err) | ||
| } | ||
| 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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.