Files
larksuite-cli/internal/secplugin/config.go

278 lines
8.5 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package secplugin implements the ~/.lark-cli/sec_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
// - optional "proxy injects token" mode via placeholder tokens (SEC_AUTH)
//
// In sec plugin mode, certain common CLI env vars (APP_ID / BRAND / DEFAULT_AS /
// STRICT_MODE) can also be set in sec_config.json so sandboxes can avoid
// environment injection. When both are present, environment variables win.
package secplugin
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"
)
// SEC plugin constants cover the config file name and placeholder token values.
const (
// ConfigFileName is the fixed config file name under core.GetConfigDir().
ConfigFileName = "sec_config.json"
// SentinelUAT is the placeholder user access token used in SEC_AUTH mode.
SentinelUAT = "secplugin-managed-uat"
// SentinelTAT is the placeholder tenant access token used in SEC_AUTH mode.
SentinelTAT = "secplugin-managed-tat"
)
// Config is the on-disk config format. Keys intentionally mirror env var names.
type Config struct {
// Enable turns on sec plugin transport handling.
Enable bool `json:"LARKSUITE_CLI_SEC_ENABLE"`
// Proxy is the fixed HTTP proxy address used for all outbound requests.
Proxy string `json:"LARKSUITE_CLI_SEC_PROXY"`
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
CAPath string `json:"LARKSUITE_CLI_SEC_CA"`
// Auth enables placeholder-token mode for proxy-side credential injection.
Auth bool `json:"LARKSUITE_CLI_SEC_AUTH"`
// Optional defaults for sec plugin mode; env vars override these.
// AppID supplies the app ID when the environment does not set one.
AppID string `json:"LARKSUITE_CLI_APP_ID,omitempty"`
// Brand supplies the tenant brand when the environment does not set one.
Brand string `json:"LARKSUITE_CLI_BRAND,omitempty"` // feishu | lark
// DefaultAs supplies the default identity when the environment does not set one.
DefaultAs string `json:"LARKSUITE_CLI_DEFAULT_AS,omitempty"` // user | bot | auto
// StrictMode supplies the strict mode when the environment does not set one.
StrictMode string `json:"LARKSUITE_CLI_STRICT_MODE,omitempty"` // user | bot | off
}
// Path returns the absolute path to the sec plugin config file.
func Path() string {
return filepath.Join(core.GetConfigDir(), ConfigFileName)
}
// loadOnce guards one-time SEC config loading for process-wide transport reuse.
var loadOnce sync.Once
// loadCfg stores the cached SEC 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/sec_config.json once and caches the parsed result.
// Environment variables (CliSec*) take precedence over config file values.
//
// Returns (nil, nil) only when:
// - the config file does not exist AND
// - none of the SEC-related env vars are present.
func Load() (*Config, error) {
loadOnce.Do(func() {
// Start from env-only config if any SEC 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 sec plugin config %q: %w", p, err)
return
}
b, err := vfs.ReadFile(p)
if err != nil {
loadErr = fmt.Errorf("failed to read sec plugin config %q: %w", p, err)
return
}
var fileCfg Config
if err := json.Unmarshal(b, &fileCfg); err != nil {
loadErr = fmt.Errorf("invalid sec 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 SEC plugin mode is enabled.
func (c *Config) Enabled() bool { return c != nil && c.Enable }
// AuthEnabled reports whether SEC_AUTH token placeholder mode is enabled.
func (c *Config) AuthEnabled() bool { return c != nil && c.Enable && c.Auth }
// loadFromEnv builds a config from SEC-related environment variables only.
// It reports whether any SEC-related environment variable was present.
func loadFromEnv() (*Config, bool, error) {
_, hasEnable := os.LookupEnv(envvars.CliSecEnable)
_, hasProxy := os.LookupEnv(envvars.CliSecProxy)
_, hasCA := os.LookupEnv(envvars.CliSecCA)
_, hasAuth := os.LookupEnv(envvars.CliSecAuth)
hasAny := hasEnable || hasProxy || hasCA || hasAuth
if !hasAny {
return nil, false, nil
}
cfg := &Config{}
if err := applyEnvOverrides(cfg); err != nil {
return nil, true, err
}
return cfg, true, nil
}
// applyEnvOverrides copies SEC-related environment variable values into cfg.
func applyEnvOverrides(cfg *Config) error {
if v, ok := os.LookupEnv(envvars.CliSecEnable); ok {
b, err := parseBoolEnv(envvars.CliSecEnable, v)
if err != nil {
return err
}
cfg.Enable = b
}
if v, ok := os.LookupEnv(envvars.CliSecAuth); ok {
b, err := parseBoolEnv(envvars.CliSecAuth, v)
if err != nil {
return err
}
cfg.Auth = b
}
if v, ok := os.LookupEnv(envvars.CliSecProxy); ok {
cfg.Proxy = v
}
if v, ok := os.LookupEnv(envvars.CliSecCA); 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 SEC 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.CliSecProxy)
}
redacted := redactProxyURL(raw)
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", envvars.CliSecProxy, redacted, err)
}
if u.Scheme != "http" {
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliSecProxy, redacted)
}
if u.Host == "" {
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliSecProxy, 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.CliSecProxy, redacted)
}
if u.Port() == "" {
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliSecProxy, redacted)
}
if u.Path != "" && u.Path != "/" {
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliSecProxy, redacted)
}
if u.RawQuery != "" {
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliSecProxy, redacted)
}
if u.Fragment != "" {
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliSecProxy, 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 SEC 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
}