diff --git a/cmd/config/auth_proxy.go b/cmd/config/auth_proxy.go new file mode 100644 index 000000000..d1b74e5f1 --- /dev/null +++ b/cmd/config/auth_proxy.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/sidecar" +) + +func NewCmdConfigAuthProxy(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth-proxy", + Short: "Manage trusted auth proxy hosts", + } + cmd.AddCommand(newCmdConfigAuthProxyTrust(f)) + cmd.AddCommand(newCmdConfigAuthProxyUntrust(f)) + cmd.AddCommand(newCmdConfigAuthProxyList(f)) + return cmd +} + +func newCmdConfigAuthProxyTrust(f *cmdutil.Factory) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "trust https://host[:port]", + Short: "Trust a remote HTTPS auth proxy host", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigAuthProxyTrust(f, args[0], yes) + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "confirm trusting this remote auth proxy host") + cmdutil.SetRisk(cmd, cmdutil.RiskHighRiskWrite) + return cmd +} + +func newCmdConfigAuthProxyUntrust(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "untrust host[:port]", + Short: "Remove a trusted remote auth proxy host", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runConfigAuthProxyUntrust(f, args[0]) + }, + } + cmdutil.SetRisk(cmd, "write") + return cmd +} + +func newCmdConfigAuthProxyList(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List trusted remote auth proxy hosts", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + return output.Errorf(output.ExitValidation, "config", "failed to load auth proxy config: %v", err) + } + output.PrintJson(f.IOStreams.Out, map[string]any{ + "trustedHosts": cfg.TrustedHosts, + }) + return nil + }, + } + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func runConfigAuthProxyTrust(f *cmdutil.Factory, rawHost string, confirmed bool) error { + if err := rejectAgentAuthProxyTrust(); err != nil { + return err + } + host, err := sidecar.NormalizeRemoteProxyTrustHost(rawHost) + if err != nil { + return output.ErrValidation("invalid auth proxy host: %v", err) + } + if !confirmed { + return authProxyTrustConfirmationRequired(host) + } + + changed := false + if err := core.UpdateAuthProxyConfig(func(cfg *core.AuthProxyConfig) { + for _, existing := range cfg.TrustedHosts { + normalized, err := sidecar.NormalizeRemoteProxyTrustHost(existing) + if err == nil && normalized == host { + return + } + } + cfg.TrustedHosts = append(cfg.TrustedHosts, host) + changed = true + }); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save auth proxy trust config: %v", err) + } + + if changed { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Trusted auth proxy host %q", host)) + } else { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Auth proxy host %q already trusted", host)) + } + output.PrintJson(f.IOStreams.Out, map[string]any{ + "trustedHost": host, + "changed": changed, + }) + return nil +} + +func rejectAgentAuthProxyTrust() error { + ws := core.DetectWorkspaceFromEnv(os.Getenv) + if ws.IsLocal() { + return nil + } + return &errs.ValidationError{Problem: errs.Problem{ + Category: errs.CategoryValidation, + Subtype: errs.SubtypeInvalidArgument, + Message: fmt.Sprintf("refusing to trust a remote auth proxy from %s agent workspace; run this command outside the agent environment", ws.Display()), + Hint: "run `lark-cli config auth-proxy trust https://host[:port] --yes` from a normal user shell outside the agent environment after verifying the host.", + }} +} + +func authProxyTrustConfirmationRequired(host string) error { + return &errs.ConfirmationRequiredError{ + Problem: errs.Problem{ + Category: errs.CategoryConfirmation, + Subtype: errs.SubtypeConfirmationRequired, + Message: fmt.Sprintf("trusting remote auth proxy host %q requires confirmation", host), + Hint: "Trusting a remote auth proxy allows future lark-cli requests, proxy session material, and business request bodies to flow through that host. Rerun from a normal user shell with --yes only after verifying the host.", + }, + Risk: cmdutil.RiskHighRiskWrite, + Action: "config auth-proxy trust", + } +} + +func runConfigAuthProxyUntrust(f *cmdutil.Factory, rawHost string) error { + host, err := sidecar.NormalizeRemoteProxyTrustHost(rawHost) + if err != nil { + return output.ErrValidation("invalid auth proxy host: %v", err) + } + + changed := false + if err := core.UpdateAuthProxyConfig(func(cfg *core.AuthProxyConfig) { + next := cfg.TrustedHosts[:0] + for _, existing := range cfg.TrustedHosts { + normalized, err := sidecar.NormalizeRemoteProxyTrustHost(existing) + if err == nil && normalized == host { + changed = true + continue + } + next = append(next, existing) + } + cfg.TrustedHosts = next + }); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save auth proxy trust config: %v", err) + } + + if changed { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Removed trusted auth proxy host %q", host)) + } else { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Auth proxy host %q was not trusted", host)) + } + output.PrintJson(f.IOStreams.Out, map[string]any{ + "trustedHost": host, + "changed": changed, + }) + return nil +} diff --git a/cmd/config/auth_proxy_test.go b/cmd/config/auth_proxy_test.go new file mode 100644 index 000000000..f86752760 --- /dev/null +++ b/cmd/config/auth_proxy_test.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestConfigAuthProxyTrustCmd_RiskAndYesFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + cmd := newCmdConfigAuthProxyTrust(f) + + risk, ok := cmdutil.GetRisk(cmd) + if !ok || risk != cmdutil.RiskHighRiskWrite { + t.Fatalf("risk = %q, %v; want %q", risk, ok, cmdutil.RiskHighRiskWrite) + } + if cmd.Flags().Lookup("yes") == nil { + t.Fatal("trust command should expose --yes confirmation flag") + } +} + +func TestConfigAuthProxyTrustCmd_RequiresConfirmation(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + cmd := newCmdConfigAuthProxyTrust(f) + cmd.SetArgs([]string{"https://gate.example.com"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected confirmation error without --yes") + } + var confirmErr *errs.ConfirmationRequiredError + if !errors.As(err, &confirmErr) { + t.Fatalf("error = %T %v, want *errs.ConfirmationRequiredError", err, err) + } + if confirmErr.Risk != cmdutil.RiskHighRiskWrite { + t.Fatalf("risk = %q, want %q", confirmErr.Risk, cmdutil.RiskHighRiskWrite) + } + if confirmErr.Subtype != errs.SubtypeConfirmationRequired { + t.Fatalf("subtype = %q, want %q", confirmErr.Subtype, errs.SubtypeConfirmationRequired) + } + + cfg, loadErr := core.LoadAuthProxyConfig() + if loadErr != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", loadErr) + } + if len(cfg.TrustedHosts) != 0 { + t.Fatalf("TrustedHosts = %#v, want empty after refused confirmation", cfg.TrustedHosts) + } +} + +func TestConfigAuthProxyTrustCmd_RefusesAgentWorkspaceEvenWithYes(t *testing.T) { + tests := []struct { + name string + env map[string]string + }{ + {name: "openclaw", env: map[string]string{"OPENCLAW_HOME": "/tmp/openclaw"}}, + {name: "hermes", env: map[string]string{"HERMES_HOME": "/tmp/hermes"}}, + {name: "lark channel", env: map[string]string{"LARK_CHANNEL": "1"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + for k, v := range tt.env { + t.Setenv(k, v) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + cmd := newCmdConfigAuthProxyTrust(f) + cmd.SetArgs([]string{"https://gate.example.com", "--yes"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected trust to be refused in agent workspace") + } + if !strings.Contains(err.Error(), "outside the agent environment") { + t.Fatalf("error = %v, want outside-agent guidance", err) + } + + cfg, loadErr := core.LoadAuthProxyConfig() + if loadErr != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", loadErr) + } + if len(cfg.TrustedHosts) != 0 { + t.Fatalf("TrustedHosts = %#v, want empty after agent refusal", cfg.TrustedHosts) + } + }) + } +} + +func TestConfigAuthProxyTrustCmd_ConfirmedLocalTrustsHost(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + cmd := newCmdConfigAuthProxyTrust(f) + cmd.SetArgs([]string{"https://gate.example.com", "--yes"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() error = %v", err) + } + + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", err) + } + if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("TrustedHosts = %#v, want gate.example.com", cfg.TrustedHosts) + } +} + +func TestConfigAuthProxyTrustRun_PersistsCanonicalTrustedHost(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + if err := runConfigAuthProxyTrust(f, "https://GATE.example.com:443", true); err != nil { + t.Fatalf("runConfigAuthProxyTrust() error = %v", err) + } + + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", err) + } + if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("TrustedHosts = %#v, want gate.example.com", cfg.TrustedHosts) + } +} + +func TestConfigAuthProxyTrustRun_IsIdempotent(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + if err := runConfigAuthProxyTrust(f, "https://gate.example.com", true); err != nil { + t.Fatalf("first trust error = %v", err) + } + if err := runConfigAuthProxyTrust(f, "gate.example.com", true); err != nil { + t.Fatalf("second trust error = %v", err) + } + + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", err) + } + if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("TrustedHosts = %#v, want one gate.example.com", cfg.TrustedHosts) + } +} + +func TestConfigAuthProxyTrustRun_RejectsHTTP(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + if err := runConfigAuthProxyTrust(f, "http://gate.example.com", true); err == nil { + t.Fatal("expected HTTP auth proxy trust to be rejected") + } +} + +func TestConfigAuthProxyUntrustRun_RemovesTrustedHost(t *testing.T) { + clearAgentEnv(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, nil) + + if err := runConfigAuthProxyTrust(f, "https://gate.example.com", true); err != nil { + t.Fatalf("trust error = %v", err) + } + if err := runConfigAuthProxyUntrust(f, "gate.example.com"); err != nil { + t.Fatalf("untrust error = %v", err) + } + + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", err) + } + if len(cfg.TrustedHosts) != 0 { + t.Fatalf("TrustedHosts = %#v, want empty", cfg.TrustedHosts) + } +} diff --git a/cmd/config/config.go b/cmd/config/config.go index f3c643fd5..3159816b4 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -29,6 +29,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdConfigBind(f, nil)) cmd.AddCommand(NewCmdConfigRemove(f, nil)) cmd.AddCommand(NewCmdConfigShow(f, nil)) + cmd.AddCommand(NewCmdConfigAuthProxy(f)) cmd.AddCommand(NewCmdConfigDefaultAs(f)) cmd.AddCommand(NewCmdConfigStrictMode(f)) cmd.AddCommand(NewCmdConfigPolicy(f)) diff --git a/extension/credential/sidecar/provider.go b/extension/credential/sidecar/provider.go index 99948939a..466a9a561 100644 --- a/extension/credential/sidecar/provider.go +++ b/extension/credential/sidecar/provider.go @@ -3,11 +3,11 @@ //go:build authsidecar -// Package sidecar provides a noop credential provider for the auth sidecar -// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies -// placeholder credentials so the CLI's auth pipeline can proceed normally. -// Real tokens are never present in the sandbox; the sidecar transport -// interceptor routes requests to the trusted sidecar process instead. +// Package sidecar provides a noop credential provider for auth proxy mode. +// When LARKSUITE_CLI_AUTH_PROXY is set, this provider supplies placeholder +// credentials so the CLI's auth pipeline can proceed normally. Real tokens +// are never present in the sandbox; the transport interceptor routes requests +// to the trusted local sidecar or remote managed auth proxy instead. package sidecar import ( @@ -16,6 +16,7 @@ import ( "os" "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -36,7 +37,8 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err return nil, nil // not in sidecar mode, skip } - if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { + endpoint, err := sidecar.ParseProxyEndpoint(proxyAddr) + if err != nil { return nil, &credential.BlockError{ Provider: "sidecar", Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err), @@ -51,10 +53,37 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err } } - if os.Getenv(envvars.CliProxyKey) == "" { + switch endpoint.Mode { + case sidecar.ProxyModeLocal: + if os.Getenv(envvars.CliProxyKey) == "" { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: envvars.CliAuthProxy + " is set for local sidecar mode but " + envvars.CliProxyKey + " is missing", + } + } + case sidecar.ProxyModeRemote: + if err := requireTrustedRemoteProxy(endpoint); err != nil { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: err.Error(), + } + } + if os.Getenv(envvars.CliProxyKey) == "" { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: envvars.CliAuthProxy + " is set for remote auth proxy mode but " + envvars.CliProxyKey + " is missing", + } + } + if os.Getenv(envvars.CliProxySession) == "" { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: envvars.CliAuthProxy + " is set for remote auth proxy mode but " + envvars.CliProxySession + " is missing", + } + } + default: return nil, &credential.BlockError{ Provider: "sidecar", - Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing", + Reason: fmt.Sprintf("invalid proxy mode %q", endpoint.Mode), } } @@ -100,6 +129,20 @@ func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, err return acct, nil } +func requireTrustedRemoteProxy(endpoint sidecar.ProxyEndpoint) error { + if endpoint.Mode != sidecar.ProxyModeRemote { + return nil + } + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + return fmt.Errorf("failed to load auth proxy trust config: %w", err) + } + if sidecar.IsTrustedRemoteProxyHost(endpoint.Host, cfg.TrustedHosts) { + return nil + } + return fmt.Errorf("remote auth proxy host %q is not trusted; run `lark-cli config auth-proxy trust https://%s` outside the agent environment", endpoint.Host, endpoint.Host) +} + // ResolveToken returns a sentinel token whose value encodes the token type. // The transport interceptor reads this sentinel to determine the identity // (user vs bot), strips it, and the sidecar injects the real token. diff --git a/extension/credential/sidecar/provider_test.go b/extension/credential/sidecar/provider_test.go index 1abdce063..dc287e0cc 100644 --- a/extension/credential/sidecar/provider_test.go +++ b/extension/credential/sidecar/provider_test.go @@ -8,9 +8,11 @@ package sidecar import ( "context" "os" + "strings" "testing" "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -39,6 +41,21 @@ func unsetEnv(t *testing.T, key string) { }) } +func trustRemoteProxy(t *testing.T, hosts ...string) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + AuthProxy: &core.AuthProxyConfig{TrustedHosts: hosts}, + Apps: []core.AppConfig{{ + AppId: "cli_existing", + AppSecret: core.PlainSecret("secret"), + Brand: core.BrandFeishu, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } +} + func TestResolveAccount_NotActive(t *testing.T) { unsetEnv(t, envvars.CliAuthProxy) @@ -55,6 +72,7 @@ func TestResolveAccount_NotActive(t *testing.T) { func TestResolveAccount_Active(t *testing.T) { setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") setEnv(t, envvars.CliProxyKey, "test-key") + unsetEnv(t, envvars.CliProxySession) setEnv(t, envvars.CliAppID, "cli_test123") setEnv(t, envvars.CliBrand, "lark") unsetEnv(t, envvars.CliDefaultAs) @@ -82,9 +100,62 @@ func TestResolveAccount_Active(t *testing.T) { } } +func TestResolveAccount_RemoteHTTPSActive(t *testing.T) { + trustRemoteProxy(t, "auth-proxy.example.com") + setEnv(t, envvars.CliAuthProxy, "https://auth-proxy.example.com") + setEnv(t, envvars.CliProxyKey, "proxy-signing-key") + setEnv(t, envvars.CliProxySession, "proxy-session") + setEnv(t, envvars.CliAppID, "cli_test123") + unsetEnv(t, envvars.CliBrand) + unsetEnv(t, envvars.CliDefaultAs) + unsetEnv(t, envvars.CliStrictMode) + + p := &Provider{} + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if acct == nil { + t.Fatal("expected non-nil account") + } + if acct.AppID != "cli_test123" { + t.Errorf("AppID = %q, want %q", acct.AppID, "cli_test123") + } + if acct.Brand != credential.BrandFeishu { + t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandFeishu) + } + if acct.AppSecret != credential.NoAppSecret { + t.Errorf("AppSecret should be NoAppSecret, got %q", acct.AppSecret) + } + if acct.SupportedIdentities != credential.SupportsAll { + t.Errorf("SupportedIdentities = %d, want %d (SupportsAll)", acct.SupportedIdentities, credential.SupportsAll) + } +} + +func TestResolveAccount_RemoteHTTPSUntrustedProxy(t *testing.T) { + trustRemoteProxy(t, "trusted-proxy.example.com") + setEnv(t, envvars.CliAuthProxy, "https://evil.example.com") + setEnv(t, envvars.CliProxyKey, "proxy-signing-key") + setEnv(t, envvars.CliProxySession, "proxy-session") + setEnv(t, envvars.CliAppID, "cli_test") + + p := &Provider{} + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error when remote proxy host is not trusted") + } + if _, ok := err.(*credential.BlockError); !ok { + t.Fatalf("expected BlockError, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), "not trusted") { + t.Fatalf("error should mention trust failure, got: %v", err) + } +} + func TestResolveAccount_MissingProxyKey(t *testing.T) { setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") unsetEnv(t, envvars.CliProxyKey) + unsetEnv(t, envvars.CliProxySession) setEnv(t, envvars.CliAppID, "cli_test") p := &Provider{} @@ -97,9 +168,50 @@ func TestResolveAccount_MissingProxyKey(t *testing.T) { } } +func TestResolveAccount_RemoteHTTPSMissingProxySession(t *testing.T) { + trustRemoteProxy(t, "auth-proxy.example.com") + setEnv(t, envvars.CliAuthProxy, "https://auth-proxy.example.com") + setEnv(t, envvars.CliProxyKey, "proxy-signing-key") + unsetEnv(t, envvars.CliProxySession) + setEnv(t, envvars.CliAppID, "cli_test") + + p := &Provider{} + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error when PROXY_SESSION is missing") + } + if _, ok := err.(*credential.BlockError); !ok { + t.Fatalf("expected BlockError, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), envvars.CliProxySession) { + t.Fatalf("error should mention %s, got: %v", envvars.CliProxySession, err) + } +} + +func TestResolveAccount_RemoteHTTPSMissingProxyKey(t *testing.T) { + trustRemoteProxy(t, "auth-proxy.example.com") + setEnv(t, envvars.CliAuthProxy, "https://auth-proxy.example.com") + unsetEnv(t, envvars.CliProxyKey) + setEnv(t, envvars.CliProxySession, "proxy-session") + setEnv(t, envvars.CliAppID, "cli_test") + + p := &Provider{} + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error when remote proxy signing key is missing") + } + if _, ok := err.(*credential.BlockError); !ok { + t.Fatalf("expected BlockError, got %T: %v", err, err) + } + if !strings.Contains(err.Error(), envvars.CliProxyKey) { + t.Fatalf("error should mention %s, got: %v", envvars.CliProxyKey, err) + } +} + func TestResolveAccount_MissingAppID(t *testing.T) { setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") setEnv(t, envvars.CliProxyKey, "test-key") + unsetEnv(t, envvars.CliProxySession) unsetEnv(t, envvars.CliAppID) p := &Provider{} diff --git a/extension/transport/sidecar/interceptor.go b/extension/transport/sidecar/interceptor.go index 6d928bd8a..e46de6cb4 100644 --- a/extension/transport/sidecar/interceptor.go +++ b/extension/transport/sidecar/interceptor.go @@ -3,12 +3,11 @@ //go:build authsidecar -// Package sidecar provides a transport interceptor for the auth sidecar -// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all -// outgoing requests are rewritten to the sidecar address. The interceptor -// strips placeholder credentials, injects proxy headers, and signs each -// request with HMAC-SHA256. No custom DialContext is needed — Go's -// standard http.Transport connects to the sidecar via plain HTTP. +// Package sidecar provides a transport interceptor for auth proxy mode. When +// LARKSUITE_CLI_AUTH_PROXY is set, outgoing sentinel-authenticated requests +// are rewritten to either a local HTTP sidecar or a remote HTTPS managed auth +// proxy. The interceptor strips placeholder credentials, injects proxy +// headers, and signs each request with HMAC-SHA256. package sidecar import ( @@ -21,6 +20,7 @@ import ( "strings" "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -40,21 +40,42 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor if proxyAddr == "" { return nil } - if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { + endpoint, err := sidecar.ParseProxyEndpoint(proxyAddr) + if err != nil { fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err) return nil } - key := os.Getenv(envvars.CliProxyKey) + if err := requireTrustedRemoteProxy(endpoint); err != nil { + fmt.Fprintf(os.Stderr, "WARNING: %s is set but remote auth proxy is not trusted: %v\n", envvars.CliAuthProxy, err) + return nil + } + + signingKey, proxySession := proxyAuthMaterial(endpoint.Mode) + if signingKey == "" { + fmt.Fprintf(os.Stderr, "WARNING: %s is set but required auth material is missing for %s auth proxy mode\n", envvars.CliAuthProxy, endpoint.Mode) + return nil + } + if endpoint.Mode == sidecar.ProxyModeRemote && proxySession == "" { + fmt.Fprintf(os.Stderr, "WARNING: %s is set but %s is missing for remote auth proxy mode\n", envvars.CliAuthProxy, envvars.CliProxySession) + return nil + } + return &Interceptor{ - key: []byte(key), - sidecarHost: sidecar.ProxyHost(proxyAddr), + key: []byte(signingKey), + proxyScheme: endpoint.Scheme, + sidecarHost: endpoint.Host, + proxyMode: endpoint.Mode, + proxySession: proxySession, } } // Interceptor rewrites requests for the sidecar proxy. type Interceptor struct { - key []byte // HMAC signing key - sidecarHost string // sidecar host:port for URL rewriting + key []byte // HMAC signing key + proxyScheme string // proxy scheme for URL rewriting + sidecarHost string // proxy host[:port] for URL rewriting + proxyMode sidecar.ProxyMode // local sidecar or remote managed proxy + proxySession string // remote auth proxy session credential } // PreRoundTrip rewrites the request for sidecar routing when it carries a @@ -107,6 +128,7 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, req.Header.Del("Authorization") req.Header.Del(sidecar.HeaderMCPUAT) req.Header.Del(sidecar.HeaderMCPTAT) + req.Header.Del(sidecar.HeaderProxySession) bodySHA := sidecar.BodySHA256(bodyBytes) req.Header.Set(sidecar.HeaderBodySHA256, bodySHA) @@ -129,9 +151,16 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) req.Header.Set(sidecar.HeaderProxyTimestamp, ts) req.Header.Set(sidecar.HeaderProxySignature, sig) + if i.proxyMode == sidecar.ProxyModeRemote && i.proxySession != "" { + req.Header.Set(sidecar.HeaderProxySession, i.proxySession) + } - // 5. Rewrite URL to route through sidecar - req.URL.Scheme = "http" + // 5. Rewrite URL to route through the configured auth proxy. + proxyScheme := i.proxyScheme + if proxyScheme == "" { + proxyScheme = "http" + } + req.URL.Scheme = proxyScheme req.URL.Host = i.sidecarHost return nil // no post-hook needed @@ -170,9 +199,34 @@ func init() { if proxyAddr == "" { return } - if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { + if _, err := sidecar.ParseProxyEndpoint(proxyAddr); err != nil { fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err) return } transport.Register(&Provider{}) } + +func proxyAuthMaterial(mode sidecar.ProxyMode) (signingKey, proxySession string) { + switch mode { + case sidecar.ProxyModeRemote: + return os.Getenv(envvars.CliProxyKey), os.Getenv(envvars.CliProxySession) + case sidecar.ProxyModeLocal, "": + return os.Getenv(envvars.CliProxyKey), "" + default: + return "", "" + } +} + +func requireTrustedRemoteProxy(endpoint sidecar.ProxyEndpoint) error { + if endpoint.Mode != sidecar.ProxyModeRemote { + return nil + } + cfg, err := core.LoadAuthProxyConfig() + if err != nil { + return fmt.Errorf("failed to load auth proxy trust config: %w", err) + } + if sidecar.IsTrustedRemoteProxyHost(endpoint.Host, cfg.TrustedHosts) { + return nil + } + return fmt.Errorf("remote auth proxy host %q is not trusted; run `lark-cli config auth-proxy trust https://%s` outside the agent environment", endpoint.Host, endpoint.Host) +} diff --git a/extension/transport/sidecar/interceptor_test.go b/extension/transport/sidecar/interceptor_test.go index 2cb0def1f..369ce76c8 100644 --- a/extension/transport/sidecar/interceptor_test.go +++ b/extension/transport/sidecar/interceptor_test.go @@ -7,11 +7,14 @@ package sidecar import ( "bytes" + "context" "errors" "io" "net/http" "testing" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" ) @@ -32,6 +35,21 @@ func (b *failingBody) Close() error { return nil } +func trustRemoteProxy(t *testing.T, hosts ...string) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + AuthProxy: &core.AuthProxyConfig{TrustedHosts: hosts}, + Apps: []core.AppConfig{{ + AppId: "cli_existing", + AppSecret: core.PlainSecret("secret"), + Brand: core.BrandFeishu, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } +} + func TestInterceptor_PreRoundTrip(t *testing.T) { key := []byte("test-key-for-hmac-signing-32byte!") interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"} @@ -97,6 +115,86 @@ func TestInterceptor_PreRoundTrip(t *testing.T) { } } +func TestProviderResolveInterceptor_RemoteHTTPSProxy(t *testing.T) { + trustRemoteProxy(t, "auth-proxy.example.com:8443") + t.Setenv(envvars.CliAuthProxy, "https://auth-proxy.example.com:8443") + t.Setenv(envvars.CliProxySession, "proxy-session") + t.Setenv(envvars.CliProxyKey, "proxy-signing-key") + + interceptor := (&Provider{}).ResolveInterceptor(context.Background()) + if interceptor == nil { + t.Fatal("expected remote HTTPS proxy interceptor") + } + + body := []byte(`{"msg":"hello"}`) + req, _ := http.NewRequest("POST", "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", io.NopCloser(bytes.NewReader(body))) + req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT) + req.Header.Set("X-Cli-Source", "lark-cli") + + interceptor.PreRoundTrip(req) + + if req.URL.Scheme != "https" { + t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https") + } + if req.URL.Host != "auth-proxy.example.com:8443" { + t.Errorf("host = %q, want %q", req.URL.Host, "auth-proxy.example.com:8443") + } + if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" { + t.Errorf("target = %q, want %q", target, "https://open.feishu.cn") + } + if session := req.Header.Get(sidecar.HeaderProxySession); session != "proxy-session" { + t.Errorf("%s = %q, want proxy-session", sidecar.HeaderProxySession, session) + } + if auth := req.Header.Get("Authorization"); auth != "" { + t.Errorf("Authorization header should be stripped, got %q", auth) + } + if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot { + t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot) + } + if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" { + t.Errorf("X-Cli-Source should be preserved, got %q", src) + } + + canonical := sidecar.CanonicalRequest{ + Version: sidecar.ProtocolV1, + Method: "POST", + Host: "open.feishu.cn", + PathAndQuery: "/open-apis/im/v1/messages?receive_id_type=chat_id", + BodySHA256: sidecar.BodySHA256(body), + Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp), + Identity: sidecar.IdentityBot, + AuthHeader: "Authorization", + } + if err := sidecar.Verify([]byte("proxy-signing-key"), canonical, req.Header.Get(sidecar.HeaderProxySignature)); err != nil { + t.Fatalf("remote proxy signature should verify with proxy signing key: %v", err) + } + if err := sidecar.Verify([]byte("proxy-session"), canonical, req.Header.Get(sidecar.HeaderProxySignature)); err == nil { + t.Fatal("remote proxy signature must not verify with transmitted session credential") + } +} + +func TestProviderResolveInterceptor_RemoteHTTPSUntrustedProxy(t *testing.T) { + trustRemoteProxy(t, "trusted-proxy.example.com") + t.Setenv(envvars.CliAuthProxy, "https://evil.example.com") + t.Setenv(envvars.CliProxySession, "proxy-session") + t.Setenv(envvars.CliProxyKey, "proxy-signing-key") + + if interceptor := (&Provider{}).ResolveInterceptor(context.Background()); interceptor != nil { + t.Fatal("expected nil interceptor when remote proxy host is not trusted") + } +} + +func TestProviderResolveInterceptor_RemoteHTTPSMissingProxyKey(t *testing.T) { + trustRemoteProxy(t, "auth-proxy.example.com") + t.Setenv(envvars.CliAuthProxy, "https://auth-proxy.example.com") + t.Setenv(envvars.CliProxySession, "proxy-session") + t.Setenv(envvars.CliProxyKey, "") + + if interceptor := (&Provider{}).ResolveInterceptor(context.Background()); interceptor != nil { + t.Fatal("expected nil interceptor when remote proxy signing key is missing") + } +} + func TestInterceptor_BotIdentity(t *testing.T) { interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} diff --git a/internal/core/config.go b/internal/core/config.go index 040b59b38..4e8a21846 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "path/filepath" "strings" "unicode/utf8" @@ -59,10 +60,18 @@ func (a *AppConfig) ProfileName() string { // MultiAppConfig is the multi-app config file format. type MultiAppConfig struct { - StrictMode StrictMode `json:"strictMode,omitempty"` - CurrentApp string `json:"currentApp,omitempty"` - PreviousApp string `json:"previousApp,omitempty"` - Apps []AppConfig `json:"apps"` + StrictMode StrictMode `json:"strictMode,omitempty"` + CurrentApp string `json:"currentApp,omitempty"` + PreviousApp string `json:"previousApp,omitempty"` + AuthProxy *AuthProxyConfig `json:"authProxy,omitempty"` + Apps []AppConfig `json:"apps"` +} + +// AuthProxyConfig stores local trust decisions for auth proxy mode. Runtime +// session material stays in env; this config only answers which remote HTTPS +// proxy origins the local user has trusted. +type AuthProxyConfig struct { + TrustedHosts []string `json:"trustedHosts,omitempty"` } // CurrentAppConfig returns the currently active app config. @@ -189,7 +198,18 @@ func GetConfigPath() string { // LoadMultiAppConfig loads multi-app config from disk. func LoadMultiAppConfig() (*MultiAppConfig, error) { - data, err := vfs.ReadFile(GetConfigPath()) + multi, err := loadMultiAppConfigUnchecked(GetConfigPath()) + if err != nil { + return nil, err + } + if len(multi.Apps) == 0 { + return nil, fmt.Errorf("invalid config format: no apps") + } + return multi, nil +} + +func loadMultiAppConfigUnchecked(path string) (*MultiAppConfig, error) { + data, err := vfs.ReadFile(path) if err != nil { return nil, err } @@ -198,15 +218,54 @@ func LoadMultiAppConfig() (*MultiAppConfig, error) { if err := json.Unmarshal(data, &multi); err != nil { return nil, fmt.Errorf("invalid config format: %w", err) } - if len(multi.Apps) == 0 { - return nil, fmt.Errorf("invalid config format: no apps") - } return &multi, nil } +// LoadAuthProxyConfig loads auth proxy trust config without requiring an app +// profile. Trust decisions are user-level security policy, so they are stored +// in the base config rather than workspace config; an agent workspace may use +// trust created by the user, but cannot create it via the trust command. +func LoadAuthProxyConfig() (AuthProxyConfig, error) { + multi, err := loadMultiAppConfigUnchecked(filepath.Join(GetBaseConfigDir(), "config.json")) + if errors.Is(err, os.ErrNotExist) { + return AuthProxyConfig{}, nil + } + if err != nil { + return AuthProxyConfig{}, err + } + if multi.AuthProxy == nil { + return AuthProxyConfig{}, nil + } + return *multi.AuthProxy, nil +} + +// UpdateAuthProxyConfig edits auth proxy trust config while preserving any app +// profiles already present in the base config.json. +func UpdateAuthProxyConfig(update func(*AuthProxyConfig)) error { + baseDir := GetBaseConfigDir() + basePath := filepath.Join(baseDir, "config.json") + multi, err := loadMultiAppConfigUnchecked(basePath) + if errors.Is(err, os.ErrNotExist) { + multi = &MultiAppConfig{Apps: []AppConfig{}} + } else if err != nil { + return err + } + if multi.AuthProxy == nil { + multi.AuthProxy = &AuthProxyConfig{} + } + update(multi.AuthProxy) + if len(multi.AuthProxy.TrustedHosts) == 0 { + multi.AuthProxy = nil + } + return saveMultiAppConfigAt(multi, baseDir, basePath) +} + // SaveMultiAppConfig saves config to disk. func SaveMultiAppConfig(config *MultiAppConfig) error { - dir := GetConfigDir() + return saveMultiAppConfigAt(config, GetConfigDir(), GetConfigPath()) +} + +func saveMultiAppConfigAt(config *MultiAppConfig, dir, path string) error { if err := vfs.MkdirAll(dir, 0700); err != nil { return err } @@ -214,7 +273,7 @@ func SaveMultiAppConfig(config *MultiAppConfig) error { if err != nil { return err } - return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600) + return validate.AtomicWrite(path, append(data, '\n'), 0600) } // RequireConfig loads the single-app config using the default profile resolution. diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 3d727c504..a046b29bd 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -6,6 +6,8 @@ package core import ( "encoding/json" "errors" + "os" + "path/filepath" "testing" "github.com/larksuite/cli/internal/keychain" @@ -60,6 +62,7 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) { func TestMultiAppConfig_RoundTrip(t *testing.T) { config := &MultiAppConfig{ + AuthProxy: &AuthProxyConfig{TrustedHosts: []string{"gate.example.com"}}, Apps: []AppConfig{{ AppId: "cli_test", AppSecret: PlainSecret("s"), Brand: BrandLark, Lang: "zh", Users: []AppUser{}, @@ -83,6 +86,122 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) { if got.Apps[0].Brand != BrandLark { t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark) } + if got.AuthProxy == nil || len(got.AuthProxy.TrustedHosts) != 1 || got.AuthProxy.TrustedHosts[0] != "gate.example.com" { + t.Errorf("AuthProxy = %#v, want gate.example.com", got.AuthProxy) + } +} + +func TestLoadAuthProxyConfig_ToleratesMissingOrNoAppConfig(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + cfg, err := LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() missing file error = %v", err) + } + if len(cfg.TrustedHosts) != 0 { + t.Fatalf("TrustedHosts = %#v, want empty", cfg.TrustedHosts) + } + + if err := SaveMultiAppConfig(&MultiAppConfig{ + AuthProxy: &AuthProxyConfig{TrustedHosts: []string{"gate.example.com"}}, + Apps: []AppConfig{}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + cfg, err = LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() no-app config error = %v", err) + } + if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("TrustedHosts = %#v, want gate.example.com", cfg.TrustedHosts) + } + + if _, err := LoadMultiAppConfig(); err == nil { + t.Fatal("LoadMultiAppConfig() should still reject no-app config") + } +} + +func TestUpdateAuthProxyConfig_PreservesExistingApps(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := SaveMultiAppConfig(&MultiAppConfig{ + CurrentApp: "default", + Apps: []AppConfig{{ + Name: "default", + AppId: "cli_test", + AppSecret: PlainSecret("secret"), + Brand: BrandFeishu, + }}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + if err := UpdateAuthProxyConfig(func(cfg *AuthProxyConfig) { + cfg.TrustedHosts = append(cfg.TrustedHosts, "gate.example.com") + }); err != nil { + t.Fatalf("UpdateAuthProxyConfig() error = %v", err) + } + + got, err := LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if got.CurrentApp != "default" || len(got.Apps) != 1 || got.Apps[0].AppId != "cli_test" { + t.Fatalf("app config was not preserved: %#v", got) + } + if got.AuthProxy == nil || len(got.AuthProxy.TrustedHosts) != 1 || got.AuthProxy.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("AuthProxy = %#v, want gate.example.com", got.AuthProxy) + } +} + +func TestUpdateAuthProxyConfig_CreatesConfigWhenMissing(t *testing.T) { + orig := CurrentWorkspace() + t.Cleanup(func() { SetCurrentWorkspace(orig) }) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + if err := UpdateAuthProxyConfig(func(cfg *AuthProxyConfig) { + cfg.TrustedHosts = []string{"gate.example.com"} + }); err != nil { + t.Fatalf("UpdateAuthProxyConfig() error = %v", err) + } + + cfg, err := LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", err) + } + if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("TrustedHosts = %#v, want gate.example.com", cfg.TrustedHosts) + } + + if _, err := os.Stat(GetConfigPath()); err != nil { + t.Fatalf("config file was not created: %v", err) + } +} + +func TestAuthProxyConfig_UsesBaseConfigAcrossAgentWorkspaces(t *testing.T) { + orig := CurrentWorkspace() + t.Cleanup(func() { SetCurrentWorkspace(orig) }) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + SetCurrentWorkspace(WorkspaceLocal) + if err := UpdateAuthProxyConfig(func(cfg *AuthProxyConfig) { + cfg.TrustedHosts = []string{"gate.example.com"} + }); err != nil { + t.Fatalf("UpdateAuthProxyConfig() error = %v", err) + } + + SetCurrentWorkspace(WorkspaceHermes) + cfg, err := LoadAuthProxyConfig() + if err != nil { + t.Fatalf("LoadAuthProxyConfig() error = %v", err) + } + if len(cfg.TrustedHosts) != 1 || cfg.TrustedHosts[0] != "gate.example.com" { + t.Fatalf("TrustedHosts = %#v, want base-config trust from agent workspace", cfg.TrustedHosts) + } + + if _, err := os.Stat(filepath.Join(GetBaseConfigDir(), "hermes", "config.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("agent workspace config should not be created by auth proxy trust, stat err = %v", err) + } } func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) { diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 05818af6e..a526b909f 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -13,8 +13,9 @@ const ( CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" // Sidecar proxy (auth proxy mode) - CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384" - CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar + CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // local sidecar or remote HTTPS auth proxy address + CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with auth proxy + CliProxySession = "LARKSUITE_CLI_PROXY_SESSION" // session credential for remote HTTPS auth proxy // Content safety scanning mode CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" diff --git a/sidecar/hmac_test.go b/sidecar/hmac_test.go index f5a691c2e..06650e70e 100644 --- a/sidecar/hmac_test.go +++ b/sidecar/hmac_test.go @@ -161,6 +161,9 @@ func TestValidateProxyAddr(t *testing.T) { "http://gateway.docker.internal:16384", // trailing slash is tolerated "http://127.0.0.1:8080/", + // remote managed auth proxy + "https://auth-proxy.example.com", + "https://auth-proxy.example.com:443", } for _, addr := range valid { if err := ValidateProxyAddr(addr); err != nil { @@ -173,7 +176,17 @@ func TestValidateProxyAddr(t *testing.T) { "foobar", "ftp://127.0.0.1:16384", "http://", + "http://:16384", "http://127.0.0.1:16384/some/path", + "http://127.0.0.1:16384?debug=true", + "http://127.0.0.1:16384#debug", + "https://:443", + "https://auth-proxy.example.com:", + "https://auth-proxy.example.com:99999", + "https://open.feishu.cn", + "https://auth-proxy.example.com/some/path", + "https://auth-proxy.example.com?debug=true", + "https://auth-proxy.example.com#debug", ":16384", } for _, addr := range invalid { @@ -259,27 +272,200 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) { } } -// TestValidateProxyAddr_HTTPSRejected pins the current contract: https is -// rejected explicitly (not lumped into a generic "bad scheme" error) because -// the interceptor hardcodes http and would silently downgrade an https URL -// otherwise. The message must mention https so users understand why their -// perfectly-looking config is refused. -func TestValidateProxyAddr_HTTPSRejected(t *testing.T) { +func TestValidateProxyAddr_RemoteHTTPSProxy(t *testing.T) { for _, addr := range []string{ - "https://127.0.0.1:16384", - "https://sidecar.corp.internal:443", + "https://auth-proxy.example.com", + "https://auth-proxy.example.com:443", + "https://10.0.0.10:8443", } { - err := ValidateProxyAddr(addr) - if err == nil { - t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr) - continue - } - if !strings.Contains(err.Error(), "https") { - t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err) + if err := ValidateProxyAddr(addr); err != nil { + t.Errorf("ValidateProxyAddr(%q): unexpected error: %v", addr, err) } } } +func TestValidateProxyAddr_RejectsRemoteHTTP(t *testing.T) { + err := ValidateProxyAddr("http://auth-proxy.example.com:8080") + if err == nil { + t.Fatal("expected remote HTTP proxy to be rejected") + } + if msg := err.Error(); !strings.Contains(msg, "loopback") && !strings.Contains(msg, "same-host") { + t.Errorf("error should explain same-host requirement for HTTP, got: %v", err) + } +} + +func TestParseProxyEndpoint(t *testing.T) { + tests := []struct { + name string + addr string + wantMode ProxyMode + wantScheme string + wantHost string + }{ + { + name: "bare local address", + addr: "127.0.0.1:16384", + wantMode: ProxyModeLocal, + wantScheme: "http", + wantHost: "127.0.0.1:16384", + }, + { + name: "local HTTP URL", + addr: "http://localhost:16384/", + wantMode: ProxyModeLocal, + wantScheme: "http", + wantHost: "localhost:16384", + }, + { + name: "remote HTTPS URL", + addr: "https://auth-proxy.example.com", + wantMode: ProxyModeRemote, + wantScheme: "https", + wantHost: "auth-proxy.example.com", + }, + { + name: "remote HTTPS URL with port", + addr: "https://auth-proxy.example.com:8443", + wantMode: ProxyModeRemote, + wantScheme: "https", + wantHost: "auth-proxy.example.com:8443", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseProxyEndpoint(tt.addr) + if err != nil { + t.Fatalf("ParseProxyEndpoint(%q) unexpected error: %v", tt.addr, err) + } + if got.Mode != tt.wantMode { + t.Errorf("Mode = %q, want %q", got.Mode, tt.wantMode) + } + if got.Scheme != tt.wantScheme { + t.Errorf("Scheme = %q, want %q", got.Scheme, tt.wantScheme) + } + if got.Host != tt.wantHost { + t.Errorf("Host = %q, want %q", got.Host, tt.wantHost) + } + }) + } +} + +func TestNormalizeRemoteProxyTrustHost(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"https://auth-proxy.example.com", "auth-proxy.example.com"}, + {"https://AUTH-PROXY.example.com:443", "auth-proxy.example.com"}, + {"auth-proxy.example.com", "auth-proxy.example.com"}, + {"auth-proxy.example.com:8443", "auth-proxy.example.com:8443"}, + {"https://[::1]:8443", "[::1]:8443"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := NormalizeRemoteProxyTrustHost(tt.input) + if err != nil { + t.Fatalf("NormalizeRemoteProxyTrustHost(%q) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("NormalizeRemoteProxyTrustHost(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeRemoteProxyTrustHostRejectsInvalidInput(t *testing.T) { + for _, input := range []string{ + "", + "http://auth-proxy.example.com", + "https://", + "https://:443", + "https://auth-proxy.example.com/path", + "https://auth-proxy.example.com?debug=true", + "https://auth-proxy.example.com#debug", + "https://user@auth-proxy.example.com", + "https://auth-proxy.example.com:99999", + } { + t.Run(input, func(t *testing.T) { + if _, err := NormalizeRemoteProxyTrustHost(input); err == nil { + t.Fatalf("expected NormalizeRemoteProxyTrustHost(%q) to fail", input) + } + }) + } +} + +func TestIsTrustedRemoteProxyHost(t *testing.T) { + trusted := []string{ + "https://auth-proxy.example.com", + "gate.example.com:8443", + } + for _, host := range []string{ + "auth-proxy.example.com", + "AUTH-PROXY.EXAMPLE.COM:443", + "gate.example.com:8443", + } { + t.Run("trusted_"+host, func(t *testing.T) { + if !IsTrustedRemoteProxyHost(host, trusted) { + t.Fatalf("expected %q to match trusted hosts %v", host, trusted) + } + }) + } + for _, host := range []string{ + "evil.example.com", + "auth-proxy.example.com:8443", + "gate.example.com", + } { + t.Run("untrusted_"+host, func(t *testing.T) { + if IsTrustedRemoteProxyHost(host, trusted) { + t.Fatalf("expected %q not to match trusted hosts %v", host, trusted) + } + }) + } +} + +func TestParseProxyTarget(t *testing.T) { + tests := []struct { + target string + want string + }{ + {"https://open.feishu.cn", "open.feishu.cn"}, + {"https://open.feishu.cn:443", "open.feishu.cn:443"}, + {"https://open.larksuite.com", "open.larksuite.com"}, + } + for _, tt := range tests { + t.Run(tt.target, func(t *testing.T) { + got, err := ParseProxyTarget(tt.target) + if err != nil { + t.Fatalf("ParseProxyTarget(%q) unexpected error: %v", tt.target, err) + } + if got != tt.want { + t.Fatalf("ParseProxyTarget(%q) = %q, want %q", tt.target, got, tt.want) + } + }) + } +} + +func TestParseProxyTargetRejectsUnsafeTargets(t *testing.T) { + for _, target := range []string{ + "", + "http://open.feishu.cn", + "https://", + "https://:443", + "https://open.feishu.cn:", + "https://open.feishu.cn:99999", + "https://open.feishu.cn/path", + "https://open.feishu.cn?tenant=abc", + "https://open.feishu.cn#frag", + "https://user@open.feishu.cn", + } { + t.Run(target, func(t *testing.T) { + if _, err := ParseProxyTarget(target); err == nil { + t.Fatalf("expected ParseProxyTarget(%q) to fail", target) + } + }) + } +} + func TestProxyHost(t *testing.T) { tests := []struct { input string @@ -288,6 +474,7 @@ func TestProxyHost(t *testing.T) { {"http://127.0.0.1:16384", "127.0.0.1:16384"}, {"http://0.0.0.0:8080", "0.0.0.0:8080"}, {"http://host.docker.internal:16384/", "host.docker.internal:16384"}, + {"https://auth-proxy.example.com:8443", "auth-proxy.example.com:8443"}, {"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme } for _, tt := range tests { diff --git a/sidecar/protocol.go b/sidecar/protocol.go index 4b8f9c62f..70ad5242b 100644 --- a/sidecar/protocol.go +++ b/sidecar/protocol.go @@ -2,14 +2,15 @@ // SPDX-License-Identifier: MIT // Package sidecar defines the wire protocol shared between the CLI client -// (running inside a sandbox) and the auth sidecar proxy (running in a -// trusted environment). Communication uses plain HTTP. +// (running inside a sandbox) and an auth proxy. The proxy can be a local +// same-host sidecar over HTTP or a remote managed auth proxy over HTTPS. package sidecar import ( "fmt" "net" "net/url" + "strconv" "strings" ) @@ -46,6 +47,11 @@ const ( // token into. Defaults to "Authorization" for standard OpenAPI requests. // MCP requests use "X-Lark-MCP-UAT" or "X-Lark-MCP-TAT". HeaderProxyAuthHeader = "X-Lark-Proxy-Auth-Header" + + // HeaderProxySession authenticates requests to a remote managed auth + // proxy. Local same-host sidecars use HeaderProxySignature with a local + // HMAC key instead and must not require this header. + HeaderProxySession = "X-Lark-Proxy-Session" ) // MCP auth headers used by the Lark MCP protocol. @@ -75,6 +81,23 @@ const MaxTimestampDrift = 60 // DefaultListenAddr is the default sidecar listen address (localhost only). const DefaultListenAddr = "127.0.0.1:16384" +// ProxyMode classifies the auth proxy transport model. +type ProxyMode string + +const ( + // ProxyModeLocal is a same-host sidecar reachable over HTTP. + ProxyModeLocal ProxyMode = "local" + // ProxyModeRemote is a managed auth proxy reachable over HTTPS. + ProxyModeRemote ProxyMode = "remote" +) + +// ProxyEndpoint is a validated proxy endpoint ready for request rewriting. +type ProxyEndpoint struct { + Scheme string + Host string + Mode ProxyMode +} + // sameHostAliases names DNS aliases commonly used to reach the host running // the sandbox across a container / VM boundary. Traffic to these names stays // on the physical machine (via a virtual bridge), so a plaintext sidecar @@ -89,6 +112,15 @@ var sameHostAliases = map[string]bool{ "gateway.docker.internal": true, // Docker Desktop alt name } +var reservedRemoteProxyHosts = map[string]bool{ + "open.feishu.cn": true, + "open.larksuite.com": true, + "mcp.feishu.cn": true, + "mcp.larksuite.com": true, + "accounts.feishu.cn": true, + "accounts.larksuite.com": true, +} + // isSameHost returns true when host is either a loopback IP or a recognized // same-host DNS alias. Does not perform DNS resolution — a tampered /etc/hosts // that points an alias elsewhere is out of scope (attacker with that access @@ -117,18 +149,16 @@ func errNotSameHost(addr string) error { // ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value. // Accepted formats: -// - http://host:port -// - host:port (bare address, treated as http) +// - http://host:port (local same-host sidecar) +// - host:port (local same-host sidecar, treated as http) +// - https://host[:port] (remote managed auth proxy) // -// Host must be loopback or in sameHostAliases. The sidecar pattern is -// inherently same-machine; cross-machine deployment is a different product -// and is not supported by this feature. +// Plain HTTP is allowed only for loopback or sameHostAliases. Remote managed +// proxies must use HTTPS because the auth proxy session and business request +// data traverse the network. // -// https:// is rejected because sidecar is a same-host pattern: loopback -// and virtual same-host bridges don't traverse any untrusted medium, so -// TLS adds no security. Cross-machine deployment is out of scope (see the -// host constraint above), so there is no scenario today where https -// provides a real benefit over http on loopback. +// Path, query, fragment, and userinfo are rejected to avoid ambiguous base +// paths, phishing-style URLs, and silently ignored policy hints. // // userinfo (user:pass@) is rejected unconditionally — the sidecar protocol // does not use basic auth, and the syntactic slot exists only as a phishing @@ -136,57 +166,215 @@ func errNotSameHost(addr string) error { // // Returns an error if the value is not a valid proxy address. func ValidateProxyAddr(addr string) error { + _, err := ParseProxyEndpoint(addr) + return err +} + +// ParseProxyEndpoint validates and classifies an auth proxy address. +func ParseProxyEndpoint(addr string) (ProxyEndpoint, error) { if addr == "" { - return fmt.Errorf("proxy address is empty") + return ProxyEndpoint{}, fmt.Errorf("proxy address is empty") } // Bare host:port (no scheme) — validate as a net address. if !strings.Contains(addr, "://") { host, port, err := net.SplitHostPort(addr) if err != nil { - return fmt.Errorf("invalid proxy address %q: expected host:port or http://host:port", addr) + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: expected host:port, http://host:port, or https://host[:port]", addr) } if host == "" || port == "" { - return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr) + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr) + } + if err := validatePort(port); err != nil { + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: %w", addr, err) } if !isSameHost(host) { - return errNotSameHost(addr) + return ProxyEndpoint{}, errNotSameHost(addr) } - return nil + return ProxyEndpoint{Scheme: "http", Host: net.JoinHostPort(host, port), Mode: ProxyModeLocal}, nil } u, err := url.Parse(addr) if err != nil { - return fmt.Errorf("invalid proxy address %q: %w", addr, err) + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: %w", addr, err) } if u.User != nil { - return fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr) - } - if u.Scheme == "https" { - return fmt.Errorf("invalid proxy address %q: use http:// — sidecar is "+ - "same-host only (loopback or virtual same-host bridge), so TLS adds "+ - "no security; cross-machine deployment is out of scope", addr) + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: userinfo is not allowed", addr) } - if u.Scheme != "http" { - return fmt.Errorf("invalid proxy address %q: scheme must be http", addr) + if u.Scheme != "http" && u.Scheme != "https" { + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: scheme must be http or https", addr) } if u.Host == "" { - return fmt.Errorf("invalid proxy address %q: missing host", addr) + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: missing host", addr) + } + host, err := validateURLAuthority(addr, u) + if err != nil { + return ProxyEndpoint{}, err } if u.Path != "" && u.Path != "/" { - return fmt.Errorf("invalid proxy address %q: path is not allowed", addr) + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: path is not allowed", addr) + } + if u.RawQuery != "" { + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: query is not allowed", addr) + } + if u.Fragment != "" { + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: fragment is not allowed", addr) + } + + switch u.Scheme { + case "http": + // u.Hostname() strips the port and unwraps IPv6 brackets. + if !isSameHost(host) { + return ProxyEndpoint{}, errNotSameHost(addr) + } + return ProxyEndpoint{Scheme: "http", Host: u.Host, Mode: ProxyModeLocal}, nil + case "https": + if isReservedRemoteProxyHost(u.Host) { + return ProxyEndpoint{}, fmt.Errorf("invalid proxy address %q: host %q is a Lark/Feishu upstream endpoint, not an auth proxy", addr, u.Host) + } + return ProxyEndpoint{Scheme: "https", Host: u.Host, Mode: ProxyModeRemote}, nil + } + panic("unreachable proxy scheme validation") +} + +func validateURLAuthority(addr string, u *url.URL) (string, error) { + host := u.Hostname() + if host == "" { + return "", fmt.Errorf("invalid proxy address %q: missing host", addr) + } + if strings.HasSuffix(u.Host, ":") { + return "", fmt.Errorf("invalid proxy address %q: port must not be empty", addr) } - // u.Hostname() strips the port and unwraps IPv6 brackets. - if !isSameHost(u.Hostname()) { - return errNotSameHost(addr) + if port := u.Port(); port != "" { + if err := validatePort(port); err != nil { + return "", fmt.Errorf("invalid proxy address %q: %w", addr, err) + } + } + return host, nil +} + +func validatePort(port string) error { + n, err := strconv.Atoi(port) + if err != nil { + return fmt.Errorf("invalid port %q", port) + } + if n < 1 || n > 65535 { + return fmt.Errorf("port %q out of range", port) } return nil } +func canonicalHTTPSAuthority(authority string) (string, error) { + u, err := url.Parse("https://" + authority) + if err != nil { + return "", fmt.Errorf("invalid proxy host %q: %w", authority, err) + } + if u.Host == "" || u.Scheme != "https" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { + return "", fmt.Errorf("invalid proxy host %q", authority) + } + host, err := validateURLAuthority(authority, u) + if err != nil { + return "", err + } + host = strings.ToLower(host) + port := u.Port() + if port == "" || port == "443" { + if strings.Contains(host, ":") { + return "[" + host + "]", nil + } + return host, nil + } + return net.JoinHostPort(host, port), nil +} + +// NormalizeRemoteProxyTrustHost canonicalizes config entries for trusted +// remote HTTPS auth proxies. It accepts either "https://host[:port]" or +// "host[:port]" and strips the default HTTPS port. +func NormalizeRemoteProxyTrustHost(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("trusted auth proxy host is empty") + } + if strings.Contains(value, "://") { + endpoint, err := ParseProxyEndpoint(value) + if err != nil { + return "", err + } + if endpoint.Mode != ProxyModeRemote { + return "", fmt.Errorf("trusted auth proxy must use https") + } + return canonicalHTTPSAuthority(endpoint.Host) + } + return canonicalHTTPSAuthority(value) +} + +// IsTrustedRemoteProxyHost reports whether endpointHost matches the trusted +// remote auth proxy host config. Entries without a port trust only the default +// HTTPS port; non-default ports must be listed explicitly. +func IsTrustedRemoteProxyHost(endpointHost string, trustedHosts []string) bool { + want, err := canonicalHTTPSAuthority(endpointHost) + if err != nil { + return false + } + for _, trusted := range trustedHosts { + got, err := NormalizeRemoteProxyTrustHost(trusted) + if err != nil { + continue + } + if got == want { + return true + } + } + return false +} + +// ParseProxyTarget validates X-Lark-Proxy-Target and returns the authority +// used for HMAC input and upstream allowlist checks. Targets must be HTTPS +// origins only: no userinfo, path, query, or fragment. +func ParseProxyTarget(target string) (string, error) { + u, err := url.Parse(target) + if err != nil { + return "", fmt.Errorf("parse: %w", err) + } + if u.Scheme != "https" { + return "", fmt.Errorf("scheme must be https, got %q", u.Scheme) + } + if u.User != nil { + return "", fmt.Errorf("userinfo not allowed") + } + if u.Host == "" { + return "", fmt.Errorf("missing host") + } + if _, err := validateURLAuthority(target, u); err != nil { + return "", err + } + if u.Path != "" && u.Path != "/" { + return "", fmt.Errorf("path not allowed (got %q)", u.Path) + } + if u.RawQuery != "" { + return "", fmt.Errorf("query not allowed") + } + if u.Fragment != "" { + return "", fmt.Errorf("fragment not allowed") + } + return u.Host, nil +} + +func isReservedRemoteProxyHost(authority string) bool { + host, err := canonicalHTTPSAuthority(authority) + if err != nil { + return false + } + return reservedRemoteProxyHosts[host] +} + // ProxyHost extracts the host:port from an AUTH_PROXY URL. // Input is expected to be an HTTP URL like "http://127.0.0.1:16384". // Returns the host:port portion for URL rewriting. func ProxyHost(authProxy string) string { + if endpoint, err := ParseProxyEndpoint(authProxy); err == nil { + return endpoint.Host + } // Strip scheme host := authProxy if i := strings.Index(host, "://"); i >= 0 { diff --git a/sidecar/server-demo/README.md b/sidecar/server-demo/README.md index 23516a70d..9586488cd 100644 --- a/sidecar/server-demo/README.md +++ b/sidecar/server-demo/README.md @@ -100,12 +100,14 @@ identification without revealing the full key. ### Sandbox env vars (complete list) -The startup banner only prints the *required* variables. Two more are -optional: +The startup banner only prints the variables required for this local demo. +The complete CLI auth-proxy environment also includes the remote proxy session +variable and optional identity controls: ```bash export LARKSUITE_CLI_AUTH_PROXY="http://..." # required (see constraints below) -export LARKSUITE_CLI_PROXY_KEY="..." # required +export LARKSUITE_CLI_PROXY_KEY="..." # required HMAC signing key +export LARKSUITE_CLI_PROXY_SESSION="..." # required for remote HTTPS managed proxy export LARKSUITE_CLI_APP_ID="cli_xxx" # required export LARKSUITE_CLI_BRAND="feishu" # required (feishu | lark) export LARKSUITE_CLI_DEFAULT_AS="user" # optional: force default identity @@ -114,16 +116,19 @@ export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to o **`LARKSUITE_CLI_AUTH_PROXY` constraints** — validated by the CLI on startup: -- Scheme must be `http://` (or bare `host:port`). `https://` is rejected - today because the interceptor does not yet perform TLS; a future PR that - wires up real TLS will relax this. -- Host must be loopback (`127.0.0.1`, `::1`) or one of the recognized - same-host aliases: `localhost`, `host.docker.internal`, +- `http://host:port` and bare `host:port` select local sidecar mode. The host + must be loopback (`127.0.0.1`, `::1`) or one of the recognized same-host + aliases: `localhost`, `host.docker.internal`, `host.containers.internal`, `host.lima.internal`, `gateway.docker.internal`. - The sidecar pattern is inherently same-machine; cross-machine deployment - is a different product (auth broker / STS) with different security - requirements (mTLS, cert rotation, per-client keys) and is not supported - by this feature. + Local sidecar mode requires `LARKSUITE_CLI_PROXY_KEY`. +- `https://host[:port]` selects remote managed auth proxy mode. The host must + first be trusted in local config with `lark-cli config auth-proxy trust + https://host[:port]`. This mode requires both `LARKSUITE_CLI_PROXY_SESSION` + and `LARKSUITE_CLI_PROXY_KEY`: the session is sent to the remote proxy, while + the key signs requests and is not transmitted. The remote proxy is + responsible for validating the session, enforcing app/tenant/user policy, + injecting real Lark/Feishu tokens, and forwarding only to approved OpenAPI + hosts. This demo server implements the local sidecar mode only. - No path, query, fragment, or `user:pass@` in the URL. **How auto identity detection works in sidecar mode**: on every invocation the diff --git a/sidecar/server-demo/forward.go b/sidecar/server-demo/forward.go index 6cb0b0109..fcc3e69fd 100644 --- a/sidecar/server-demo/forward.go +++ b/sidecar/server-demo/forward.go @@ -44,7 +44,8 @@ func isProxyHeader(key string) bool { http.CanonicalHeaderKey(sidecar.HeaderProxySignature), http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp), http.CanonicalHeaderKey(sidecar.HeaderBodySHA256), - http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader): + http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader), + http.CanonicalHeaderKey(sidecar.HeaderProxySession): return true } return false diff --git a/sidecar/server-demo/handler.go b/sidecar/server-demo/handler.go index 2fad340df..5dd5add15 100644 --- a/sidecar/server-demo/handler.go +++ b/sidecar/server-demo/handler.go @@ -7,11 +7,9 @@ package main import ( "bytes" - "fmt" "io" "log" "net/http" - "net/url" "time" "github.com/larksuite/cli/internal/core" @@ -98,7 +96,7 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } pathAndQuery := r.URL.RequestURI() - targetHost, err := parseTarget(target) + targetHost, err := sidecar.ParseProxyTarget(target) if err != nil { http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden) h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err)) @@ -179,7 +177,7 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // 7. Build forwarding request. Scheme is pinned to https here (not taken - // from the client-supplied target) so any future change to parseTarget + // from the client-supplied target) so any future change to ParseProxyTarget // cannot regress the cleartext-leak protection. forwardURL := "https://" + targetHost + pathAndQuery forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body)) @@ -237,35 +235,3 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s", r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond)) } - -// parseTarget validates X-Lark-Proxy-Target and returns the host portion for -// HMAC input and allowlist lookup. The target must be "https://" with no -// path, query, fragment, userinfo, or non-https scheme. Rejecting these shapes -// closes a token-leak channel: a compromised sandbox holding PROXY_KEY could -// otherwise request cleartext HTTP forwarding (or inject a path to a different -// endpoint than the allowlist entry implies). -func parseTarget(target string) (host string, err error) { - u, perr := url.Parse(target) - if perr != nil { - return "", fmt.Errorf("parse: %w", perr) - } - if u.Scheme != "https" { - return "", fmt.Errorf("scheme must be https, got %q", u.Scheme) - } - if u.Host == "" { - return "", fmt.Errorf("missing host") - } - if u.User != nil { - return "", fmt.Errorf("userinfo not allowed") - } - if u.Path != "" && u.Path != "/" { - return "", fmt.Errorf("path not allowed (got %q)", u.Path) - } - if u.RawQuery != "" { - return "", fmt.Errorf("query not allowed") - } - if u.Fragment != "" { - return "", fmt.Errorf("fragment not allowed") - } - return u.Host, nil -} diff --git a/sidecar/server-demo/handler_test.go b/sidecar/server-demo/handler_test.go index 2bc2a20f2..ee55b7530 100644 --- a/sidecar/server-demo/handler_test.go +++ b/sidecar/server-demo/handler_test.go @@ -18,6 +18,7 @@ import ( "testing" extcred "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/sidecar" @@ -57,6 +58,12 @@ func newTestHandler(key []byte) *proxyHandler { } } +func TestIsProxyHeader_RecognizesProxySession(t *testing.T) { + if !isProxyHeader(sidecar.HeaderProxySession) { + t.Fatalf("%s should be treated as a proxy-only header", sidecar.HeaderProxySession) + } +} + // signedReq creates a properly signed request for testing handler logic past // HMAC verification. Identity defaults to bot and auth-header to // "Authorization"; callers can override by mutating the returned request @@ -253,7 +260,7 @@ func TestParseTarget(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - host, err := parseTarget(tc.target) + host, err := sidecar.ParseProxyTarget(tc.target) if tc.wantErr { if err == nil { t.Fatalf("expected error, got host=%q", host) @@ -585,11 +592,11 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) { } func TestBuildAllowedHosts(t *testing.T) { - feishu := struct{ Open, Accounts, MCP string }{ - "https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn", + feishu := core.Endpoints{ + Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn", } - lark := struct{ Open, Accounts, MCP string }{ - "https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com", + lark := core.Endpoints{ + Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "https://mcp.larksuite.com", } hosts := buildAllowedHosts(feishu, lark) // feishu hosts diff --git a/sidecar/server-multi-tenant-demo/forward.go b/sidecar/server-multi-tenant-demo/forward.go index 67cecb1f7..55764928c 100644 --- a/sidecar/server-multi-tenant-demo/forward.go +++ b/sidecar/server-multi-tenant-demo/forward.go @@ -44,7 +44,8 @@ func isProxyHeader(key string) bool { http.CanonicalHeaderKey(sidecar.HeaderProxySignature), http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp), http.CanonicalHeaderKey(sidecar.HeaderBodySHA256), - http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader): + http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader), + http.CanonicalHeaderKey(sidecar.HeaderProxySession): return true } return false diff --git a/sidecar/server-multi-tenant-demo/handler.go b/sidecar/server-multi-tenant-demo/handler.go index c7dc9202c..10a53fa30 100644 --- a/sidecar/server-multi-tenant-demo/handler.go +++ b/sidecar/server-multi-tenant-demo/handler.go @@ -11,7 +11,6 @@ import ( "io" "log" "net/http" - "net/url" "path/filepath" "strings" "sync" @@ -199,7 +198,7 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } pathAndQuery := r.URL.RequestURI() - targetHost, err := parseTarget(target) + targetHost, err := sidecar.ParseProxyTarget(target) if err != nil { http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+err.Error(), http.StatusForbidden) h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err)) @@ -343,30 +342,3 @@ func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s%s", r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond), clientTag) } - -// parseTarget validates X-Lark-Proxy-Target and returns the host portion. -func parseTarget(target string) (host string, err error) { - u, perr := url.Parse(target) - if perr != nil { - return "", fmt.Errorf("parse: %w", perr) - } - if u.Scheme != "https" { - return "", fmt.Errorf("scheme must be https, got %q", u.Scheme) - } - if u.Host == "" { - return "", fmt.Errorf("missing host") - } - if u.User != nil { - return "", fmt.Errorf("userinfo not allowed") - } - if u.Path != "" && u.Path != "/" { - return "", fmt.Errorf("path not allowed (got %q)", u.Path) - } - if u.RawQuery != "" { - return "", fmt.Errorf("query not allowed") - } - if u.Fragment != "" { - return "", fmt.Errorf("fragment not allowed") - } - return u.Host, nil -} diff --git a/sidecar/server-multi-tenant-demo/handler_test.go b/sidecar/server-multi-tenant-demo/handler_test.go index ffdd64e68..8f4bc362c 100644 --- a/sidecar/server-multi-tenant-demo/handler_test.go +++ b/sidecar/server-multi-tenant-demo/handler_test.go @@ -60,6 +60,12 @@ func newTestHandler(key []byte) *proxyHandler { } } +func TestIsProxyHeader_RecognizesProxySession(t *testing.T) { + if !isProxyHeader(sidecar.HeaderProxySession) { + t.Fatalf("%s should be treated as a proxy-only header", sidecar.HeaderProxySession) + } +} + // signedReq creates a properly signed request for testing handler logic past // HMAC verification. Identity defaults to bot and auth-header to // "Authorization"; callers can override by mutating the returned request @@ -256,7 +262,7 @@ func TestParseTarget(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - host, err := parseTarget(tc.target) + host, err := sidecar.ParseProxyTarget(tc.target) if tc.wantErr { if err == nil { t.Fatalf("expected error, got host=%q", host)