diff --git a/.gitignore b/.gitignore index ff1490812..822e8f314 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ tests/mail/reports/ internal/registry/meta_data.json cmd/api/download.bin app.log +/sidecar-server-demo +/server-demo diff --git a/extension/credential/registry.go b/extension/credential/registry.go index 52ec9ebf8..fc71100fb 100644 --- a/extension/credential/registry.go +++ b/extension/credential/registry.go @@ -3,7 +3,10 @@ package credential -import "sync" +import ( + "sort" + "sync" +) var ( mu sync.Mutex @@ -11,12 +14,28 @@ var ( ) // Register registers a credential Provider. -// Providers are consulted in registration order. +// Providers are consulted in priority order (lowest value first). +// Providers that implement Priority() int are sorted accordingly; +// those that do not default to priority 10. // Typically called from init() via blank import. func Register(p Provider) { mu.Lock() defer mu.Unlock() providers = append(providers, p) + sort.SliceStable(providers, func(i, j int) bool { + return providerPriority(providers[i]) < providerPriority(providers[j]) + }) +} + +// providerPriority returns the priority of a provider. +// If the provider implements interface{ Priority() int }, that value is used; +// otherwise 10 is returned as the default priority. +// Lower values are consulted first. +func providerPriority(p Provider) int { + if pp, ok := p.(interface{ Priority() int }); ok { + return pp.Priority() + } + return 10 } // Providers returns all registered providers (snapshot). diff --git a/extension/credential/registry_test.go b/extension/credential/registry_test.go index e4ab0885b..0e4bf455f 100644 --- a/extension/credential/registry_test.go +++ b/extension/credential/registry_test.go @@ -37,6 +37,32 @@ func TestRegisterAndProviders(t *testing.T) { } } +type priorityProvider struct { + stubProvider + priority int +} + +func (p *priorityProvider) Priority() int { return p.priority } + +func TestRegister_PriorityOrder(t *testing.T) { + mu.Lock() + old := providers + providers = nil + mu.Unlock() + defer func() { mu.Lock(); providers = old; mu.Unlock() }() + + Register(&stubProvider{name: "env"}) // priority 10 (default) + Register(&priorityProvider{stubProvider: stubProvider{name: "sidecar"}, priority: 0}) // priority 0 (first) + + got := Providers() + if len(got) != 2 { + t.Fatalf("expected 2, got %d", len(got)) + } + if got[0].Name() != "sidecar" || got[1].Name() != "env" { + t.Errorf("expected sidecar before env, got %s, %s", got[0].Name(), got[1].Name()) + } +} + func TestProviders_ReturnsSnapshot(t *testing.T) { mu.Lock() old := providers diff --git a/extension/credential/sidecar/provider.go b/extension/credential/sidecar/provider.go new file mode 100644 index 000000000..99948939a --- /dev/null +++ b/extension/credential/sidecar/provider.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//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 + +import ( + "context" + "fmt" + "os" + + "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/sidecar" +) + +// Provider is the noop credential provider for sidecar mode. +type Provider struct{} + +func (p *Provider) Name() string { return "sidecar" } +func (p *Provider) Priority() int { return 0 } + +// ResolveAccount returns a minimal Account when sidecar mode is active. +// The account contains AppID and Brand from environment variables, a +// placeholder secret, and SupportedIdentities derived from STRICT_MODE. +// Returns nil, nil when sidecar mode is not active (AUTH_PROXY not set). +func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) { + proxyAddr := os.Getenv(envvars.CliAuthProxy) + if proxyAddr == "" { + return nil, nil // not in sidecar mode, skip + } + + if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: fmt.Sprintf("invalid %s %q: %v", envvars.CliAuthProxy, proxyAddr, err), + } + } + + appID := os.Getenv(envvars.CliAppID) + if appID == "" { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: envvars.CliAuthProxy + " is set but " + envvars.CliAppID + " is missing", + } + } + + if os.Getenv(envvars.CliProxyKey) == "" { + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: envvars.CliAuthProxy + " is set but " + envvars.CliProxyKey + " is missing", + } + } + + brand := credential.Brand(os.Getenv(envvars.CliBrand)) + if brand == "" { + brand = credential.BrandFeishu + } + + acct := &credential.Account{ + AppID: appID, + AppSecret: credential.NoAppSecret, + Brand: brand, + } + + // Parse DefaultAs + switch id := credential.Identity(os.Getenv(envvars.CliDefaultAs)); id { + case "", credential.IdentityAuto: + acct.DefaultAs = id + case credential.IdentityUser, credential.IdentityBot: + acct.DefaultAs = id + default: + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: fmt.Sprintf("invalid %s %q (want user, bot, or auto)", envvars.CliDefaultAs, id), + } + } + + // Parse SupportedIdentities from STRICT_MODE, default to SupportsAll. + switch strictMode := os.Getenv(envvars.CliStrictMode); strictMode { + case "bot": + acct.SupportedIdentities = credential.SupportsBot + case "user": + acct.SupportedIdentities = credential.SupportsUser + case "off", "": + acct.SupportedIdentities = credential.SupportsAll + default: + return nil, &credential.BlockError{ + Provider: "sidecar", + Reason: fmt.Sprintf("invalid %s %q (want bot, user, or off)", envvars.CliStrictMode, strictMode), + } + } + + return acct, nil +} + +// 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. +// Returns nil, nil when sidecar mode is not active. +func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) { + if os.Getenv(envvars.CliAuthProxy) == "" { + return nil, nil + } + + var sentinel string + switch req.Type { + case credential.TokenTypeUAT: + sentinel = sidecar.SentinelUAT + case credential.TokenTypeTAT: + sentinel = sidecar.SentinelTAT + default: + return nil, nil + } + + return &credential.Token{ + Value: sentinel, + Scopes: "", // empty → scope pre-check is skipped + Source: "sidecar", + }, nil +} + +func init() { + credential.Register(&Provider{}) +} diff --git a/extension/credential/sidecar/provider_test.go b/extension/credential/sidecar/provider_test.go new file mode 100644 index 000000000..1abdce063 --- /dev/null +++ b/extension/credential/sidecar/provider_test.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar + +package sidecar + +import ( + "context" + "os" + "testing" + + "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/sidecar" +) + +func setEnv(t *testing.T, key, value string) { + t.Helper() + old, hadOld := os.LookupEnv(key) + os.Setenv(key, value) + t.Cleanup(func() { + if hadOld { + os.Setenv(key, old) + } else { + os.Unsetenv(key) + } + }) +} + +func unsetEnv(t *testing.T, key string) { + t.Helper() + old, hadOld := os.LookupEnv(key) + os.Unsetenv(key) + t.Cleanup(func() { + if hadOld { + os.Setenv(key, old) + } + }) +} + +func TestResolveAccount_NotActive(t *testing.T) { + unsetEnv(t, envvars.CliAuthProxy) + + p := &Provider{} + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if acct != nil { + t.Fatal("expected nil account when AUTH_PROXY not set") + } +} + +func TestResolveAccount_Active(t *testing.T) { + setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envvars.CliProxyKey, "test-key") + setEnv(t, envvars.CliAppID, "cli_test123") + setEnv(t, envvars.CliBrand, "lark") + 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.BrandLark { + t.Errorf("Brand = %q, want %q", acct.Brand, credential.BrandLark) + } + 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_MissingProxyKey(t *testing.T) { + setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") + unsetEnv(t, envvars.CliProxyKey) + setEnv(t, envvars.CliAppID, "cli_test") + + p := &Provider{} + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error when PROXY_KEY is missing") + } + if _, ok := err.(*credential.BlockError); !ok { + t.Fatalf("expected BlockError, got %T: %v", err, 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.CliAppID) + + p := &Provider{} + _, err := p.ResolveAccount(context.Background()) + if err == nil { + t.Fatal("expected error when APP_ID is missing") + } + if _, ok := err.(*credential.BlockError); !ok { + t.Fatalf("expected BlockError, got %T: %v", err, err) + } +} + +func TestResolveAccount_StrictMode(t *testing.T) { + setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envvars.CliProxyKey, "test-key") + setEnv(t, envvars.CliAppID, "cli_test") + + tests := []struct { + mode string + want credential.IdentitySupport + }{ + {"bot", credential.SupportsBot}, + {"user", credential.SupportsUser}, + {"off", credential.SupportsAll}, + {"", credential.SupportsAll}, + } + + p := &Provider{} + for _, tt := range tests { + t.Run("strict_"+tt.mode, func(t *testing.T) { + if tt.mode == "" { + unsetEnv(t, envvars.CliStrictMode) + } else { + setEnv(t, envvars.CliStrictMode, tt.mode) + } + acct, err := p.ResolveAccount(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if acct.SupportedIdentities != tt.want { + t.Errorf("SupportedIdentities = %d, want %d", acct.SupportedIdentities, tt.want) + } + }) + } +} + +func TestResolveToken_NotActive(t *testing.T) { + unsetEnv(t, envvars.CliAuthProxy) + + p := &Provider{} + tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tok != nil { + t.Fatal("expected nil token when AUTH_PROXY not set") + } +} + +func TestResolveToken_Sentinels(t *testing.T) { + setEnv(t, envvars.CliAuthProxy, "http://127.0.0.1:16384") + setEnv(t, envvars.CliProxyKey, "test-key") + + p := &Provider{} + + // UAT + tok, err := p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeUAT}) + if err != nil { + t.Fatalf("UAT: unexpected error: %v", err) + } + if tok.Value != sidecar.SentinelUAT { + t.Errorf("UAT value = %q, want %q", tok.Value, sidecar.SentinelUAT) + } + if tok.Scopes != "" { + t.Errorf("UAT scopes should be empty, got %q", tok.Scopes) + } + + // TAT + tok, err = p.ResolveToken(context.Background(), credential.TokenSpec{Type: credential.TokenTypeTAT}) + if err != nil { + t.Fatalf("TAT: unexpected error: %v", err) + } + if tok.Value != sidecar.SentinelTAT { + t.Errorf("TAT value = %q, want %q", tok.Value, sidecar.SentinelTAT) + } +} diff --git a/extension/transport/sidecar/interceptor.go b/extension/transport/sidecar/interceptor.go new file mode 100644 index 000000000..6d928bd8a --- /dev/null +++ b/extension/transport/sidecar/interceptor.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//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 + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/larksuite/cli/extension/transport" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/sidecar" +) + +// Provider implements transport.Provider for the sidecar mode. +type Provider struct{} + +func (p *Provider) Name() string { return "sidecar" } + +// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active. +// Returns nil when sidecar mode is disabled or the proxy address is invalid; +// in the latter case a warning is emitted to stderr and requests fall back to +// the non-sidecar transport path (where the credential layer will typically +// block them for lack of a valid account). +func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor { + proxyAddr := os.Getenv(envvars.CliAuthProxy) + if proxyAddr == "" { + return nil + } + if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { + fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err) + return nil + } + key := os.Getenv(envvars.CliProxyKey) + return &Interceptor{ + key: []byte(key), + sidecarHost: sidecar.ProxyHost(proxyAddr), + } +} + +// Interceptor rewrites requests for the sidecar proxy. +type Interceptor struct { + key []byte // HMAC signing key + sidecarHost string // sidecar host:port for URL rewriting +} + +// PreRoundTrip rewrites the request for sidecar routing when it carries a +// sentinel token. Requests without a sentinel token (e.g. pre-signed download +// URLs) are passed through unmodified. +// +// Supports two auth patterns: +// - Standard OpenAPI: Authorization: Bearer +// - MCP protocol: X-Lark-MCP-UAT/TAT: +func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) { + identity, authHeader := detectSentinel(req) + if identity == "" { + return nil // not a sidecar-managed request, pass through + } + + // 1. Buffer the body first, before mutating any request state. A partial + // read would sign a truncated body and cause a misleading HMAC mismatch + // on the sidecar side; bail out early and let the request fall through + // unmodified so the credential layer can surface an actionable error. + var bodyBytes []byte + if req.Body != nil { + var err error + bodyBytes, err = io.ReadAll(req.Body) + _ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering + if err != nil { + fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err) + return nil + } + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + if req.GetBody != nil { + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(bodyBytes)), nil + } + } + } + + // 2. Save original target (scheme://host) + originalScheme := "https" + if req.URL.Scheme != "" { + originalScheme = req.URL.Scheme + } + originalHost := req.URL.Host + req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost) + + // 3. Set identity and tell sidecar which header to inject real token into + req.Header.Set(sidecar.HeaderProxyIdentity, identity) + req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader) + + // 4. Strip placeholder auth header(s) + req.Header.Del("Authorization") + req.Header.Del(sidecar.HeaderMCPUAT) + req.Header.Del(sidecar.HeaderMCPTAT) + + bodySHA := sidecar.BodySHA256(bodyBytes) + req.Header.Set(sidecar.HeaderBodySHA256, bodySHA) + + pathAndQuery := req.URL.RequestURI() + ts := sidecar.Timestamp() + // Cover identity and authHeader in the signature so an on-path attacker + // within the replay window cannot flip the injected token's identity or + // redirect the token into a different header. + sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{ + Version: sidecar.ProtocolV1, + Method: req.Method, + Host: originalHost, + PathAndQuery: pathAndQuery, + BodySHA256: bodySHA, + Timestamp: ts, + Identity: identity, + AuthHeader: authHeader, + }) + req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) + req.Header.Set(sidecar.HeaderProxyTimestamp, ts) + req.Header.Set(sidecar.HeaderProxySignature, sig) + + // 5. Rewrite URL to route through sidecar + req.URL.Scheme = "http" + req.URL.Host = i.sidecarHost + + return nil // no post-hook needed +} + +// detectSentinel checks both standard Authorization and MCP auth headers for +// sentinel tokens. Returns the identity ("user"/"bot") and the header name +// that carried the sentinel. +// +// Returns ("", "") when the request carries no sentinel token — typically +// requests that require no auth (e.g. pre-signed download URLs where the +// token is embedded in the URL query parameters). +func detectSentinel(req *http.Request) (identity, authHeader string) { + // Check standard Authorization: Bearer + if auth := req.Header.Get("Authorization"); auth != "" { + token := strings.TrimPrefix(auth, "Bearer ") + switch token { + case sidecar.SentinelUAT: + return sidecar.IdentityUser, "Authorization" + case sidecar.SentinelTAT: + return sidecar.IdentityBot, "Authorization" + } + } + // Check MCP headers: X-Lark-MCP-UAT/TAT: + if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT { + return sidecar.IdentityUser, sidecar.HeaderMCPUAT + } + if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT { + return sidecar.IdentityBot, sidecar.HeaderMCPTAT + } + return "", "" +} + +func init() { + proxyAddr := os.Getenv(envvars.CliAuthProxy) + if proxyAddr == "" { + return + } + if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil { + fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err) + return + } + transport.Register(&Provider{}) +} diff --git a/extension/transport/sidecar/interceptor_test.go b/extension/transport/sidecar/interceptor_test.go new file mode 100644 index 000000000..2cb0def1f --- /dev/null +++ b/extension/transport/sidecar/interceptor_test.go @@ -0,0 +1,265 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar + +package sidecar + +import ( + "bytes" + "errors" + "io" + "net/http" + "testing" + + "github.com/larksuite/cli/sidecar" +) + +// failingBody is a ReadCloser that errors on Read and tracks Close calls. +type failingBody struct { + err error + closed bool + readCall bool +} + +func (b *failingBody) Read(p []byte) (int, error) { + b.readCall = true + return 0, b.err +} + +func (b *failingBody) Close() error { + b.closed = true + return nil +} + +func TestInterceptor_PreRoundTrip(t *testing.T) { + key := []byte("test-key-for-hmac-signing-32byte!") + interceptor := &Interceptor{key: key, sidecarHost: "127.0.0.1:16384"} + + 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.SentinelUAT) + req.Header.Set("X-Cli-Source", "lark-cli") + + post := interceptor.PreRoundTrip(req) + + if post != nil { + t.Error("expected nil post hook") + } + + // URL should be rewritten to sidecar + if req.URL.Scheme != "http" { + t.Errorf("scheme = %q, want %q", req.URL.Scheme, "http") + } + if req.URL.Host != "127.0.0.1:16384" { + t.Errorf("host = %q, want %q", req.URL.Host, "127.0.0.1:16384") + } + + // Original target should be preserved + target := req.Header.Get(sidecar.HeaderProxyTarget) + if target != "https://open.feishu.cn" { + t.Errorf("target = %q, want %q", target, "https://open.feishu.cn") + } + + // Identity should be user (from SentinelUAT) + if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser { + t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser) + } + + // Authorization should be stripped + if auth := req.Header.Get("Authorization"); auth != "" { + t.Errorf("Authorization header should be stripped, got %q", auth) + } + + // HMAC headers should be set + if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" { + t.Error("signature header should be set") + } + if ts := req.Header.Get(sidecar.HeaderProxyTimestamp); ts == "" { + t.Error("timestamp header should be set") + } + if sha := req.Header.Get(sidecar.HeaderBodySHA256); sha == "" { + t.Error("body SHA256 header should be set") + } + if v := req.Header.Get(sidecar.HeaderProxyVersion); v != sidecar.ProtocolV1 { + t.Errorf("version header = %q, want %q", v, sidecar.ProtocolV1) + } + + // Non-proxy headers should be preserved + if src := req.Header.Get("X-Cli-Source"); src != "lark-cli" { + t.Errorf("X-Cli-Source should be preserved, got %q", src) + } + + // Body should still be readable + readBody, _ := io.ReadAll(req.Body) + if !bytes.Equal(readBody, body) { + t.Errorf("body should be preserved after PreRoundTrip") + } +} + +func TestInterceptor_BotIdentity(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/calendar/v4/events", nil) + req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT) + + interceptor.PreRoundTrip(req) + + if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot { + t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot) + } +} + +func TestInterceptor_NonSentinelToken_PassThrough(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + origURL := "https://some-cdn.example.com/presigned-download?token=abc" + req, _ := http.NewRequest("GET", origURL, nil) + req.Header.Set("Authorization", "Bearer some-real-token") + + post := interceptor.PreRoundTrip(req) + + // Should NOT be rewritten — no sentinel token + if post != nil { + t.Error("expected nil post hook for pass-through") + } + if req.URL.String() != origURL { + t.Errorf("URL should be unchanged, got %q", req.URL.String()) + } + if req.Header.Get(sidecar.HeaderProxyTarget) != "" { + t.Error("proxy target header should not be set for pass-through") + } + if req.Header.Get("Authorization") != "Bearer some-real-token" { + t.Error("Authorization should be preserved for pass-through") + } +} + +func TestInterceptor_NoAuth_PassThrough(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + origURL := "https://cdn.feishu.cn/download/file" + req, _ := http.NewRequest("GET", origURL, nil) + + interceptor.PreRoundTrip(req) + + // No Authorization header at all — should pass through + if req.URL.String() != origURL { + t.Errorf("URL should be unchanged for no-auth request, got %q", req.URL.String()) + } +} + +func TestInterceptor_MCP_UAT(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{"jsonrpc":"2.0"}`))) + req.Header.Set(sidecar.HeaderMCPUAT, sidecar.SentinelUAT) + + interceptor.PreRoundTrip(req) + + // Should be intercepted and rewritten + if req.URL.Host != "127.0.0.1:16384" { + t.Errorf("host = %q, want sidecar host", req.URL.Host) + } + if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityUser { + t.Errorf("identity = %q, want %q", identity, sidecar.IdentityUser) + } + if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPUAT { + t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPUAT) + } + // MCP sentinel should be stripped + if v := req.Header.Get(sidecar.HeaderMCPUAT); v != "" { + t.Errorf("MCP-UAT should be stripped, got %q", v) + } +} + +func TestInterceptor_MCP_TAT(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + req, _ := http.NewRequest("POST", "https://mcp.feishu.cn/mcp/v1/tools/call", bytes.NewReader([]byte(`{}`))) + req.Header.Set(sidecar.HeaderMCPTAT, sidecar.SentinelTAT) + + interceptor.PreRoundTrip(req) + + if identity := req.Header.Get(sidecar.HeaderProxyIdentity); identity != sidecar.IdentityBot { + t.Errorf("identity = %q, want %q", identity, sidecar.IdentityBot) + } + if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != sidecar.HeaderMCPTAT { + t.Errorf("auth header = %q, want %q", ah, sidecar.HeaderMCPTAT) + } +} + +func TestInterceptor_StandardAuth_SetsAuthorizationHeader(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil) + req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT) + + interceptor.PreRoundTrip(req) + + if ah := req.Header.Get(sidecar.HeaderProxyAuthHeader); ah != "Authorization" { + t.Errorf("auth header = %q, want %q", ah, "Authorization") + } +} + +// TestInterceptor_BodyReadError verifies that when io.ReadAll on the request +// body fails partway, PreRoundTrip skips the rewrite entirely rather than +// signing a truncated body (which would produce a misleading HMAC mismatch on +// the sidecar side) and releases the original body. +func TestInterceptor_BodyReadError(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + const origURL = "https://open.feishu.cn/open-apis/im/v1/messages" + body := &failingBody{err: errors.New("disk gremlin")} + + req, _ := http.NewRequest("POST", origURL, body) + req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT) + + post := interceptor.PreRoundTrip(req) + + if post != nil { + t.Error("expected nil post hook on body read failure") + } + + // Original body must be closed to avoid leaking fd/pipe-like resources. + if !body.readCall { + t.Error("expected ReadAll to have attempted reading from the body") + } + if !body.closed { + t.Error("expected original body to be Close()'d after read failure") + } + + // URL must NOT be rewritten — request should fall through to the next + // layer (credential) which can surface a meaningful error. + if req.URL.String() != origURL { + t.Errorf("URL should be unchanged on read failure, got %q", req.URL.String()) + } + + // No proxy/HMAC headers should leak onto the request. + for _, h := range []string{ + sidecar.HeaderProxyVersion, + sidecar.HeaderProxyTarget, + sidecar.HeaderProxySignature, + sidecar.HeaderProxyTimestamp, + sidecar.HeaderBodySHA256, + sidecar.HeaderProxyIdentity, + sidecar.HeaderProxyAuthHeader, + } { + if v := req.Header.Get(h); v != "" { + t.Errorf("%s should not be set on read failure, got %q", h, v) + } + } +} + +func TestInterceptor_EmptyBody(t *testing.T) { + interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} + + req, _ := http.NewRequest("GET", "https://open.feishu.cn/path", nil) + req.Header.Set("Authorization", "Bearer "+sidecar.SentinelTAT) + interceptor.PreRoundTrip(req) + + sha := req.Header.Get(sidecar.HeaderBodySHA256) + expectedEmpty := sidecar.BodySHA256(nil) + if sha != expectedEmpty { + t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty) + } +} diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 1d80ac1cc..ecb629fd0 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -11,4 +11,8 @@ const ( CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN" CliDefaultAs = "LARKSUITE_CLI_DEFAULT_AS" 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 ) diff --git a/main_authsidecar.go b/main_authsidecar.go new file mode 100644 index 000000000..039180a2e --- /dev/null +++ b/main_authsidecar.go @@ -0,0 +1,11 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar + +package main + +import ( + _ "github.com/larksuite/cli/extension/credential/sidecar" // activate sidecar credential provider + _ "github.com/larksuite/cli/extension/transport/sidecar" // activate sidecar transport interceptor +) diff --git a/main_noauthsidecar.go b/main_noauthsidecar.go new file mode 100644 index 000000000..514afda63 --- /dev/null +++ b/main_noauthsidecar.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !authsidecar + +// This file is the fail-closed guard for builds that do NOT include the +// `authsidecar` tag. The sidecar credential-isolation feature is only +// compiled in under that tag; deploying the plain build into an environment +// that expects sidecar isolation would silently fall back to direct env +// credential use — exactly the failure mode the feature is meant to prevent. +// +// When LARKSUITE_CLI_AUTH_PROXY is set, we refuse to run rather than ignore +// the variable. The operator either rebuilt without realizing (wrong +// artifact) or the sandbox inherited the var by accident; both cases want +// a loud startup error, not a mysterious token leak on the first API call. + +package main + +import ( + "fmt" + "io" + "os" + + "github.com/larksuite/cli/internal/envvars" +) + +func init() { + if code := checkNoAuthsidecarBuild(os.Getenv, os.Stderr); code != 0 { + os.Exit(code) + } +} + +// checkNoAuthsidecarBuild returns a non-zero exit code (and writes a +// human-readable reason to stderr) when the environment asks for sidecar +// isolation that this binary cannot provide. Factored out from init() so +// tests can exercise the decision without actually calling os.Exit. +func checkNoAuthsidecarBuild(getenv func(string) string, stderr io.Writer) int { + v := getenv(envvars.CliAuthProxy) + if v == "" { + return 0 + } + fmt.Fprintf(stderr, + "ERROR: %s is set, but this lark-cli binary was built WITHOUT the "+ + "'authsidecar' build tag.\n"+ + "The sidecar credential-isolation feature is compiled out — "+ + "running would bypass isolation and\n"+ + "send any real credentials present in the environment directly "+ + "to the Lark API.\n\n"+ + "To fix, either:\n"+ + " - rebuild the CLI with: go build -tags authsidecar\n"+ + " - or unset %s if sidecar isolation is not required\n", + envvars.CliAuthProxy, envvars.CliAuthProxy) + return 2 +} diff --git a/main_noauthsidecar_test.go b/main_noauthsidecar_test.go new file mode 100644 index 000000000..5879015c2 --- /dev/null +++ b/main_noauthsidecar_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !authsidecar + +package main + +import ( + "bytes" + "strings" + "testing" + + "github.com/larksuite/cli/internal/envvars" +) + +func TestCheckNoAuthsidecarBuild_Unset(t *testing.T) { + var stderr bytes.Buffer + code := checkNoAuthsidecarBuild(func(string) string { return "" }, &stderr) + if code != 0 { + t.Errorf("exit code = %d, want 0 when AUTH_PROXY is unset", code) + } + if stderr.Len() != 0 { + t.Errorf("stderr should be empty, got %q", stderr.String()) + } +} + +// TestCheckNoAuthsidecarBuild_Set verifies that deploying a plain build into +// a sandbox that expects sidecar isolation fails loudly at startup instead +// of silently leaking credentials through the env provider path. +func TestCheckNoAuthsidecarBuild_Set(t *testing.T) { + var stderr bytes.Buffer + env := func(k string) string { + if k == envvars.CliAuthProxy { + return "http://127.0.0.1:16384" + } + return "" + } + code := checkNoAuthsidecarBuild(env, &stderr) + if code == 0 { + t.Fatal("expected non-zero exit code when AUTH_PROXY is set") + } + msg := stderr.String() + for _, want := range []string{ + envvars.CliAuthProxy, + "authsidecar", // build-tag name must appear so operators can act on it + "rebuild", + } { + if !strings.Contains(msg, want) { + t.Errorf("stderr message missing %q; got:\n%s", want, msg) + } + } +} diff --git a/sidecar/hmac.go b/sidecar/hmac.go new file mode 100644 index 000000000..2c2f58759 --- /dev/null +++ b/sidecar/hmac.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sidecar + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// BodySHA256 returns the hex-encoded SHA-256 digest of body. +// An empty or nil body produces the SHA-256 of the empty string. +func BodySHA256(body []byte) string { + h := sha256.Sum256(body) + return hex.EncodeToString(h[:]) +} + +// CanonicalRequest is the set of fields covered by the HMAC signature. +// Clients and servers must populate every field identically for verification +// to succeed; any field that is forwarded but *not* covered by this struct can +// be tampered with inside the MaxTimestampDrift replay window without +// invalidating the signature. +// +// Version must be set to a known protocol constant (ProtocolV1). It is the +// first field in the canonical string so that a future v2 with different +// structure cannot be confused for v1 output under the same key. +type CanonicalRequest struct { + Version string // e.g. ProtocolV1 + Method string // e.g. "GET", "POST" + Host string // e.g. "open.feishu.cn" + PathAndQuery string // e.g. "/open-apis/calendar/v4/events?page_size=50" + BodySHA256 string // hex-encoded SHA-256 of the request body + Timestamp string // Unix epoch seconds string + Identity string // IdentityUser or IdentityBot + AuthHeader string // header the server should inject the real token into +} + +// canonicalString joins the fields with newlines. Field order is part of the +// protocol contract — do not reorder without bumping Version. +func (c CanonicalRequest) canonicalString() string { + return strings.Join([]string{ + c.Version, + c.Method, + c.Host, + c.PathAndQuery, + c.BodySHA256, + c.Timestamp, + c.Identity, + c.AuthHeader, + }, "\n") +} + +// Sign computes the HMAC-SHA256 signature over the canonical request string. +func Sign(key []byte, req CanonicalRequest) string { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(req.canonicalString())) + return hex.EncodeToString(mac.Sum(nil)) +} + +// Verify checks that signature matches the HMAC-SHA256 of the canonical +// request and that the timestamp is within MaxTimestampDrift seconds of now. +// Returns nil on success. +func Verify(key []byte, req CanonicalRequest, signature string) error { + ts, err := strconv.ParseInt(req.Timestamp, 10, 64) + if err != nil { + return fmt.Errorf("invalid timestamp %q: %w", req.Timestamp, err) + } + drift := math.Abs(float64(time.Now().Unix() - ts)) + if drift > MaxTimestampDrift { + return fmt.Errorf("timestamp drift %.0fs exceeds limit %ds", drift, MaxTimestampDrift) + } + expected := Sign(key, req) + if !hmac.Equal([]byte(expected), []byte(signature)) { + return fmt.Errorf("HMAC signature mismatch") + } + return nil +} + +// Timestamp returns the current Unix epoch seconds as a string. +func Timestamp() string { + return strconv.FormatInt(time.Now().Unix(), 10) +} diff --git a/sidecar/hmac_test.go b/sidecar/hmac_test.go new file mode 100644 index 000000000..f5a691c2e --- /dev/null +++ b/sidecar/hmac_test.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sidecar + +import ( + "strconv" + "strings" + "testing" + "time" +) + +func TestBodySHA256_Empty(t *testing.T) { + // SHA-256 of empty string is a well-known constant. + want := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + if got := BodySHA256(nil); got != want { + t.Errorf("BodySHA256(nil) = %q, want %q", got, want) + } + if got := BodySHA256([]byte{}); got != want { + t.Errorf("BodySHA256([]byte{}) = %q, want %q", got, want) + } +} + +func TestBodySHA256_NonEmpty(t *testing.T) { + got := BodySHA256([]byte(`{"key":"value"}`)) + if len(got) != 64 { + t.Errorf("expected 64-char hex string, got %d chars", len(got)) + } +} + +// canonical is a test helper that builds a fully-populated CanonicalRequest +// with reasonable defaults, so individual tests can override just the field +// they want to tamper with. +func canonical(override func(*CanonicalRequest)) CanonicalRequest { + c := CanonicalRequest{ + Version: ProtocolV1, + Method: "POST", + Host: "open.feishu.cn", + PathAndQuery: "/open-apis/im/v1/messages?receive_id_type=chat_id", + BodySHA256: BodySHA256([]byte(`{"content":"hello"}`)), + Timestamp: Timestamp(), + Identity: IdentityUser, + AuthHeader: "Authorization", + } + if override != nil { + override(&c) + } + return c +} + +func TestSignAndVerify(t *testing.T) { + key := []byte("test-secret-key-32bytes-long!!!!!") + req := canonical(nil) + + sig := Sign(key, req) + if len(sig) != 64 { + t.Fatalf("signature should be 64-char hex, got %d chars", len(sig)) + } + + // Valid verification + if err := Verify(key, req, sig); err != nil { + t.Fatalf("Verify failed for valid signature: %v", err) + } + + // Wrong key + if err := Verify([]byte("wrong-key"), req, sig); err == nil { + t.Error("Verify should fail with wrong key") + } + + // Each field must be covered by the signature — tampering with any one + // invalidates it. + fields := map[string]func(*CanonicalRequest){ + "version": func(c *CanonicalRequest) { c.Version = "v2" }, + "method": func(c *CanonicalRequest) { c.Method = "GET" }, + "host": func(c *CanonicalRequest) { c.Host = "evil.com" }, + "pathAndQuery": func(c *CanonicalRequest) { c.PathAndQuery = "/steal" }, + "bodySHA256": func(c *CanonicalRequest) { c.BodySHA256 = BodySHA256([]byte("tampered")) }, + "identity": func(c *CanonicalRequest) { c.Identity = IdentityBot }, + "authHeader": func(c *CanonicalRequest) { c.AuthHeader = "Cookie" }, + } + for name, mutate := range fields { + t.Run("tamper_"+name, func(t *testing.T) { + tampered := canonical(mutate) + if err := Verify(key, tampered, sig); err == nil { + t.Errorf("Verify should fail when %s is tampered", name) + } + }) + } +} + +// TestVerify_PrivilegeConfusion proves C1: without identity and authHeader in +// the canonical string, an attacker holding a captured user-signed request +// could replay it as bot (or vice versa) by flipping the header. With both +// fields now covered, such a flip must invalidate the signature. +func TestVerify_PrivilegeConfusion(t *testing.T) { + key := []byte("test-key") + signed := canonical(func(c *CanonicalRequest) { c.Identity = IdentityUser }) + sig := Sign(key, signed) + + replayed := signed + replayed.Identity = IdentityBot // attacker flips identity + if err := Verify(key, replayed, sig); err == nil { + t.Error("identity flip must invalidate signature") + } + + replayed = signed + replayed.AuthHeader = "Cookie" // attacker redirects injection target + if err := Verify(key, replayed, sig); err == nil { + t.Error("auth-header flip must invalidate signature") + } +} + +func TestVerify_TimestampDrift(t *testing.T) { + key := []byte("test-key") + + // Timestamp too old + oldTs := strconv.FormatInt(time.Now().Unix()-MaxTimestampDrift-10, 10) + oldReq := canonical(func(c *CanonicalRequest) { c.Timestamp = oldTs }) + sig := Sign(key, oldReq) + if err := Verify(key, oldReq, sig); err == nil { + t.Error("Verify should reject expired timestamp") + } + + // Timestamp too far in future + futureTs := strconv.FormatInt(time.Now().Unix()+MaxTimestampDrift+10, 10) + futureReq := canonical(func(c *CanonicalRequest) { c.Timestamp = futureTs }) + sig = Sign(key, futureReq) + if err := Verify(key, futureReq, sig); err == nil { + t.Error("Verify should reject future timestamp") + } + + // Invalid timestamp + badTs := canonical(func(c *CanonicalRequest) { c.Timestamp = "not-a-number" }) + if err := Verify(key, badTs, "sig"); err == nil { + t.Error("Verify should reject invalid timestamp") + } +} + +func TestSignDeterministic(t *testing.T) { + key := []byte("key") + req := canonical(func(c *CanonicalRequest) { c.Timestamp = "12345" }) + a, b := Sign(key, req), Sign(key, req) + if a != b { + t.Errorf("Sign should be deterministic: %q vs %q", a, b) + } +} + +func TestValidateProxyAddr(t *testing.T) { + valid := []string{ + // loopback IPs + "http://127.0.0.1:16384", + "127.0.0.1:16384", + "[::1]:16384", + "http://[::1]:16384", + // recognized same-host aliases + "http://localhost:8080", + "localhost:8080", + "http://host.docker.internal:16384", + "http://host.containers.internal:16384", + "http://host.lima.internal:16384", + "http://gateway.docker.internal:16384", + // trailing slash is tolerated + "http://127.0.0.1:8080/", + } + for _, addr := range valid { + if err := ValidateProxyAddr(addr); err != nil { + t.Errorf("ValidateProxyAddr(%q) unexpected error: %v", addr, err) + } + } + + invalid := []string{ + "", + "foobar", + "ftp://127.0.0.1:16384", + "http://", + "http://127.0.0.1:16384/some/path", + ":16384", + } + for _, addr := range invalid { + if err := ValidateProxyAddr(addr); err == nil { + t.Errorf("ValidateProxyAddr(%q) expected error, got nil", addr) + } + } +} + +// TestValidateProxyAddr_HostConstraint pins C2: the sidecar pattern is +// same-machine by definition, so the validator rejects any host that isn't +// loopback or a recognized same-host alias. Tampered /etc/hosts is out of +// scope (attacker already has ambient host access). +func TestValidateProxyAddr_HostConstraint(t *testing.T) { + sameHost := []string{ + "http://127.0.0.1:16384", + "http://localhost:8080", + "http://host.docker.internal:16384", + "http://host.containers.internal:16384", + "http://host.lima.internal:16384", + "http://gateway.docker.internal:16384", + "http://[::1]:16384", + // bare form + "127.0.0.1:16384", + "localhost:8080", + "host.docker.internal:16384", + } + for _, addr := range sameHost { + if err := ValidateProxyAddr(addr); err != nil { + t.Errorf("expected %q to pass as same-host, got: %v", addr, err) + } + } + + notSameHost := map[string]string{ + // The interesting ones — plausible misconfigurations / attacks + "public DNS name": "http://attacker.com:8080", + "cloud metadata IMDS": "http://169.254.169.254", + "private RFC1918": "http://10.0.0.1:16384", + "other RFC1918": "http://192.168.1.2:16384", + "link-local IPv4": "http://169.254.1.1:16384", + "unspecified IPv4 (0.0.0.0)": "http://0.0.0.0:16384", + "bare public IP": "http://8.8.8.8:16384", + "bare RFC1918": "10.0.0.1:16384", + } + for name, addr := range notSameHost { + t.Run(name, func(t *testing.T) { + err := ValidateProxyAddr(addr) + if err == nil { + t.Fatalf("expected rejection for %q", addr) + } + // Error must name the constraint so users know why. + msg := err.Error() + if !strings.Contains(msg, "loopback") && !strings.Contains(msg, "same-host") { + t.Errorf("error should explain same-host requirement, got: %v", err) + } + }) + } +} + +// TestValidateProxyAddr_RejectsUserinfo closes the URL-phishing vector +// http://127.0.0.1@attacker.com (where "127.0.0.1" is actually basic-auth +// userinfo and the real host is attacker.com). userinfo has no legitimate +// use in the sidecar protocol. +func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) { + for _, addr := range []string{ + "http://user@127.0.0.1:16384", + "http://user:pass@127.0.0.1:16384", + "http://127.0.0.1@attacker.com:16384", + } { + err := ValidateProxyAddr(addr) + if err == nil { + t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr) + continue + } + // Either "userinfo" (for addresses parsed with user) or the same-host + // message (for e.g. http://127.0.0.1@attacker.com where the REAL + // host parses as attacker.com) is acceptable — both reject the + // phishing attempt. + msg := err.Error() + if !strings.Contains(msg, "userinfo") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") { + t.Errorf("error should reject userinfo or flag wrong host, got: %v", err) + } + } +} + +// 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) { + for _, addr := range []string{ + "https://127.0.0.1:16384", + "https://sidecar.corp.internal:443", + } { + 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) + } + } +} + +func TestProxyHost(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"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"}, + {"127.0.0.1:16384", "127.0.0.1:16384"}, // no scheme + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := ProxyHost(tt.input); got != tt.want { + t.Errorf("ProxyHost(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/sidecar/protocol.go b/sidecar/protocol.go new file mode 100644 index 000000000..4b8f9c62f --- /dev/null +++ b/sidecar/protocol.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// 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. +package sidecar + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// ProtocolV1 is the wire-protocol version string embedded in every signed +// request. Servers must reject requests whose HeaderProxyVersion is not a +// version they understand. Bump this constant (and update the canonical +// string) for any breaking change to signing inputs. +const ProtocolV1 = "v1" + +// Proxy request headers set by the CLI transport interceptor. +const ( + // HeaderProxyVersion carries the wire-protocol version (e.g. ProtocolV1). + // Servers must reject requests whose version they do not understand. The + // value is also included in the canonical signing string so that a request + // signed for one version cannot be replayed as another. + HeaderProxyVersion = "X-Lark-Proxy-Version" + + // HeaderProxyTarget carries the original request host (e.g. "open.feishu.cn"). + HeaderProxyTarget = "X-Lark-Proxy-Target" + + // HeaderProxyIdentity carries the resolved identity type ("user" or "bot"). + HeaderProxyIdentity = "X-Lark-Proxy-Identity" + + // HeaderProxySignature carries the HMAC-SHA256 hex signature. + HeaderProxySignature = "X-Lark-Proxy-Signature" + + // HeaderProxyTimestamp carries the Unix epoch seconds string used in signing. + HeaderProxyTimestamp = "X-Lark-Proxy-Timestamp" + + // HeaderBodySHA256 carries the hex-encoded SHA-256 digest of the request body. + HeaderBodySHA256 = "X-Lark-Body-SHA256" + + // HeaderProxyAuthHeader tells the sidecar which header to inject the real + // 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" +) + +// MCP auth headers used by the Lark MCP protocol. +const ( + HeaderMCPUAT = "X-Lark-MCP-UAT" + HeaderMCPTAT = "X-Lark-MCP-TAT" +) + +// Sentinel token values returned by the noop credential provider. +// These are placeholder strings that flow through the SDK auth pipeline +// but are stripped by the transport interceptor before reaching the sidecar. +const ( + SentinelUAT = "sidecar-managed-uat" // User Access Token placeholder + SentinelTAT = "sidecar-managed-tat" // Tenant Access Token placeholder +) + +// IdentityUser and IdentityBot are the wire values for HeaderProxyIdentity. +const ( + IdentityUser = "user" + IdentityBot = "bot" +) + +// MaxTimestampDrift is the maximum allowed difference (in seconds) between +// the request timestamp and the server's current time. +const MaxTimestampDrift = 60 + +// DefaultListenAddr is the default sidecar listen address (localhost only). +const DefaultListenAddr = "127.0.0.1:16384" + +// 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 +// channel still satisfies the sidecar pattern's same-host confidentiality +// requirement. Adding to this list has real security implications — only add +// names that are universally same-host by the runtime's design. +var sameHostAliases = map[string]bool{ + "localhost": true, // universal + "host.docker.internal": true, // Docker Desktop (macOS / Windows) + "host.containers.internal": true, // Podman Desktop + "host.lima.internal": true, // Lima / colima / rancher-desktop + "gateway.docker.internal": true, // Docker Desktop alt name +} + +// 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 +// already has ambient control of the machine). +func isSameHost(host string) bool { + if sameHostAliases[host] { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + +// errNotSameHost is the shared error returned when the sidecar address does +// not resolve to the same physical host as the sandbox. Kept in one place so +// tests can look for a stable marker. +func errNotSameHost(addr string) error { + return fmt.Errorf("invalid proxy address %q: host must be loopback "+ + "(127.0.0.1 / ::1) or a recognized same-host alias "+ + "(localhost, host.docker.internal, host.containers.internal, "+ + "host.lima.internal, gateway.docker.internal). "+ + "The sidecar must run on the same physical machine as the sandbox — "+ + "cross-machine deployment is not a sidecar and is not supported", addr) +} + +// ValidateProxyAddr validates the LARKSUITE_CLI_AUTH_PROXY value. +// Accepted formats: +// - http://host:port +// - host:port (bare address, treated as http) +// +// 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. +// +// 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. +// +// userinfo (user:pass@) is rejected unconditionally — the sidecar protocol +// does not use basic auth, and the syntactic slot exists only as a phishing +// vector (e.g. http://127.0.0.1@attacker.com). +// +// Returns an error if the value is not a valid proxy address. +func ValidateProxyAddr(addr string) error { + if addr == "" { + return 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) + } + if host == "" || port == "" { + return fmt.Errorf("invalid proxy address %q: host and port must not be empty", addr) + } + if !isSameHost(host) { + return errNotSameHost(addr) + } + return nil + } + + u, err := url.Parse(addr) + if err != nil { + return 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) + } + if u.Scheme != "http" { + return fmt.Errorf("invalid proxy address %q: scheme must be http", addr) + } + if u.Host == "" { + return fmt.Errorf("invalid proxy address %q: missing host", addr) + } + if u.Path != "" && u.Path != "/" { + return fmt.Errorf("invalid proxy address %q: path is not allowed", addr) + } + // u.Hostname() strips the port and unwraps IPv6 brackets. + if !isSameHost(u.Hostname()) { + return errNotSameHost(addr) + } + return nil +} + +// 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 { + // Strip scheme + host := authProxy + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + // Strip trailing slash + host = strings.TrimRight(host, "/") + return host +} diff --git a/sidecar/server-demo/README.md b/sidecar/server-demo/README.md new file mode 100644 index 000000000..f0860a44c --- /dev/null +++ b/sidecar/server-demo/README.md @@ -0,0 +1,197 @@ +# Sidecar Server Reference Implementation + +> ⚠️ **This is a demo.** For production deployment, implement your own sidecar +> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`. + +This example shows how to implement a sidecar auth proxy server that receives +HMAC-signed requests from lark-cli sandbox clients and forwards them to the +Lark/Feishu API with real credentials injected. + +## What this demo shows + +- HMAC-SHA256 request verification (timestamp drift, body digest, signature) +- Target host allowlist + https-only target validation (anti-SSRF / anti-downgrade) +- Identity-based token resolution (UAT for user, TAT for bot) +- Auth-header allowlist: real token may only be injected into `Authorization` + / `X-Lark-MCP-UAT` / `X-Lark-MCP-TAT`, rejecting attempts to smuggle it into + `Cookie`, `User-Agent`, or other intermediate-logged headers +- Audit logging with path ID-segment sanitization and upstream error truncation +- Safe request forwarding (strips client-supplied auth headers) + +## What this demo does NOT handle + +- **TAT refresh** — the shared `DefaultTokenProvider` caches the TAT via + `sync.Once`, which never refreshes. A long-running server will return an + expired TAT after 2 hours. Production implementations should maintain a + TTL-based cache with early renewal. +- High availability / load balancing / hot key rotation +- TLS termination +- Rate limiting / per-identity quotas + +## Both sides need the right build tags + +Sidecar is split into **two separate binaries** with **different build tags**: + +| Side | Binary | Build tag | How to build | +| --- | --- | --- | --- | +| Sandbox (client) | `lark-cli` | `authsidecar` | `go build -tags authsidecar -o lark-cli .` | +| Trusted (server) | `sidecar-server-demo` | `authsidecar_demo` | `go build -tags authsidecar_demo -o sidecar-server-demo ./sidecar/server-demo/` | + +If the sandbox runs a standard `lark-cli` **without** `-tags authsidecar`, the +`LARKSUITE_CLI_AUTH_PROXY` env var is ignored and requests bypass the sidecar +entirely — real credentials (if any) leak to the sandbox. + +## Prerequisites + +The demo reuses the lark-cli credential pipeline, so the trusted machine must +have an app configured: + +```bash +lark-cli config init --new # configure app_id / app_secret (required) +lark-cli auth login # store user refresh_token in keychain + # (only required if sandbox will use --as user) +``` + +`auth login` is **only required for user identity**. If the server will only +serve bot requests (TAT), `config init` alone is enough because the TAT is +minted from `app_id + app_secret`. + +Also, the server process **must not** inherit `LARKSUITE_CLI_AUTH_PROXY` — if +it does, the sidecar credential provider would activate inside the server and +return sentinel tokens instead of real ones. The demo rejects this at startup +with a clear error, but you should make sure to `unset LARKSUITE_CLI_AUTH_PROXY` +in the server shell before launching. + +## Run + +```bash +./sidecar-server-demo \ + --listen 127.0.0.1:16384 \ + --key-file /.lark-sidecar/proxy.key \ + --log-file /.lark-sidecar/audit.log +``` + +### Flags + +| Flag | Default | Purpose | +| --- | --- | --- | +| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener | +| `--key-file` | `/.lark-sidecar/proxy.key` | Path to write the generated HMAC key (mode 0600) | +| `--log-file` | *(empty, stderr)* | Audit log output path | +| `--profile` | *(empty, active profile)* | lark-cli profile name for credential lookup | + +### Startup output + +``` +Auth sidecar listening on http://127.0.0.1:16384 +HMAC key prefix: a3b2c1d4 +Full key written to /Users/alice/.lark-sidecar/proxy.key (mode 0600) + +Set in sandbox: + export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384" + export LARKSUITE_CLI_PROXY_KEY="" + export LARKSUITE_CLI_APP_ID="cli_xxx" + export LARKSUITE_CLI_BRAND="feishu" +``` + +The `key-file` path is printed exactly as passed on the command line (relative +paths stay relative). The `HMAC key prefix` is the first 8 characters for +identification without revealing the full key. + +### Sandbox env vars (complete list) + +The startup banner only prints the *required* variables. Two more are +optional: + +```bash +export LARKSUITE_CLI_AUTH_PROXY="http://..." # required (see constraints below) +export LARKSUITE_CLI_PROXY_KEY="..." # required +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 +export LARKSUITE_CLI_STRICT_MODE="user" # optional: lock sandbox to one identity +``` + +**`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`, + `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. +- No path, query, fragment, or `user:pass@` in the URL. + +**How auto identity detection works in sidecar mode**: on every invocation the +CLI asks the sidecar to look up the logged-in user's `open_id` via +`/open-apis/authen/v1/user_info`. If that succeeds, `--as` defaults to `user`; +if it fails (trusted side has no valid user login, or the call errors out), +it falls back to `bot`. Setting `LARKSUITE_CLI_DEFAULT_AS=user` lets you +short-circuit this and always default to user regardless of the lookup +result; set it to `bot` for the opposite. + +**Note**: `LARKSUITE_CLI_STRICT_MODE` and the server's identity allowlist are +two separate enforcement points: +- `STRICT_MODE` is interpreted locally by the sandbox CLI — it rejects + `--as` values the sandbox itself disallows, before any request goes out. +- The server's allowlist is built from the **trusted-side** config's + `SupportedIdentities` (`sidecar/server-demo/allowlist.go`). The sandbox + cannot override it. + +A well-configured deployment aligns both (e.g. both set to `user` when the +app only supports user tokens), but they are computed independently. + +### Graceful shutdown + +Send `SIGINT` (`Ctrl+C`) or `SIGTERM` to stop the server. The demo drains +in-flight requests with a 5-second timeout before exiting. + +## Wire protocol + +See the [`sidecar` package on pkg.go.dev](https://pkg.go.dev/github.com/larksuite/cli/sidecar) +for protocol constants, HMAC signing/verification, and address validation utilities. + +Headers (client → server): + +| Header | Purpose | +| --- | --- | +| `X-Lark-Proxy-Version` | Wire-protocol version (currently `"v1"`). Server rejects unknown values with 400. | +| `X-Lark-Proxy-Target` | Original target **scheme + host only** (e.g. `https://open.feishu.cn`). Must be `https://`; any path/query/fragment/userinfo in this header is rejected. The path and query come from the request line itself; the server reconstructs the upstream URL as `https:// + requestURI`. | +| `X-Lark-Proxy-Identity` | `"user"` or `"bot"`. Covered by the signature. | +| `X-Lark-Proxy-Auth-Header` | Which header the server should inject real token into. Covered by the signature. | +| `X-Lark-Proxy-Signature` | hex-encoded HMAC-SHA256 | +| `X-Lark-Proxy-Timestamp` | Unix seconds (drift ≤ 60s) | +| `X-Lark-Body-SHA256` | hex-encoded SHA-256 of the request body | + +Signing material (newline-separated, in order): + +```text +version +method +host +pathAndQuery +bodySHA256 +timestamp +identity +authHeader +``` + +Every field above is part of the canonical string. In particular, `identity` +and `authHeader` are covered so a captured request cannot be replayed with +its identity flipped (bot↔user) or its auth-header redirected (e.g. into +`Cookie`) inside the 60s drift window. + +## Source layout + +| File | Purpose | +| --- | --- | +| `main.go` | Entry point: flag parsing, server lifecycle | +| `handler.go` | `proxyHandler.ServeHTTP` — main request flow | +| `forward.go` | Forwarding HTTP client + proxy-header filter | +| `allowlist.go` | Target host / identity allowlists | +| `audit.go` | Log path/error sanitization | +| `handler_test.go` | Unit tests for all of the above | diff --git a/sidecar/server-demo/allowlist.go b/sidecar/server-demo/allowlist.go new file mode 100644 index 000000000..d37e855b4 --- /dev/null +++ b/sidecar/server-demo/allowlist.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar_demo + +package main + +import ( + "strings" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/sidecar" +) + +// buildAllowedHosts extracts the set of allowed target hostnames from +// multiple brand endpoints so the sidecar can serve both feishu and lark clients. +func buildAllowedHosts(endpoints ...core.Endpoints) map[string]bool { + hosts := make(map[string]bool) + for _, ep := range endpoints { + for _, u := range []string{ep.Open, ep.Accounts, ep.MCP} { + if idx := strings.Index(u, "://"); idx >= 0 { + hosts[u[idx+3:]] = true + } + } + } + return hosts +} + +// buildAllowedIdentities returns the set of identities the sidecar is allowed to serve, +// based on the trusted-side strict mode / SupportedIdentities configuration. +func buildAllowedIdentities(cfg *core.CliConfig) map[string]bool { + ids := make(map[string]bool) + switch { + case cfg.SupportedIdentities == 0: // unknown/unset → allow both + ids[sidecar.IdentityUser] = true + ids[sidecar.IdentityBot] = true + case cfg.SupportedIdentities&1 != 0: // SupportsUser bit + ids[sidecar.IdentityUser] = true + } + if cfg.SupportedIdentities == 0 || cfg.SupportedIdentities&2 != 0 { // SupportsBot bit + ids[sidecar.IdentityBot] = true + } + return ids +} diff --git a/sidecar/server-demo/audit.go b/sidecar/server-demo/audit.go new file mode 100644 index 000000000..6ed03b4de --- /dev/null +++ b/sidecar/server-demo/audit.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar_demo + +package main + +import "strings" + +// sanitizePath strips query parameters and replaces ID-like path segments +// with ":id" to prevent document tokens, chat IDs, etc. from leaking into logs. +// Example: /open-apis/docx/v1/documents/doxcnXXXX/blocks → /open-apis/docx/v1/documents/:id/blocks +func sanitizePath(pathAndQuery string) string { + // Strip query + path := pathAndQuery + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + // Replace ID-like segments (8+ chars, not a pure API keyword) + parts := strings.Split(path, "/") + for i, p := range parts { + if looksLikeID(p) { + parts[i] = ":id" + } + } + return strings.Join(parts, "/") +} + +// looksLikeID returns true if a path segment appears to be a resource identifier +// rather than an API route keyword. Heuristic: 8+ chars and contains a digit. +func looksLikeID(seg string) bool { + if len(seg) < 8 { + return false + } + for _, c := range seg { + if c >= '0' && c <= '9' { + return true + } + } + return false +} + +// sanitizeError returns a safe error string for logging, capped at 200 bytes +// to avoid dumping upstream response bodies into audit logs. +func sanitizeError(err error) string { + s := err.Error() + if len(s) > 200 { + return s[:200] + "..." + } + return s +} diff --git a/sidecar/server-demo/forward.go b/sidecar/server-demo/forward.go new file mode 100644 index 000000000..6cb0b0109 --- /dev/null +++ b/sidecar/server-demo/forward.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar_demo + +package main + +import ( + "fmt" + "net/http" + "time" + + "github.com/larksuite/cli/sidecar" +) + +// newForwardClient creates an HTTP client for forwarding requests to the +// Lark API. It strips Authorization on cross-host redirects and disables +// proxy to prevent real tokens from leaking through environment proxies. +func newForwardClient() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil // never proxy the trusted hop + return &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + if len(via) > 0 && req.URL.Host != via[0].URL.Host { + req.Header.Del("Authorization") + req.Header.Del(sidecar.HeaderMCPUAT) + req.Header.Del(sidecar.HeaderMCPTAT) + } + return nil + }, + } +} + +// isProxyHeader returns true for headers specific to the sidecar protocol. +func isProxyHeader(key string) bool { + switch http.CanonicalHeaderKey(key) { + case http.CanonicalHeaderKey(sidecar.HeaderProxyTarget), + http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity), + http.CanonicalHeaderKey(sidecar.HeaderProxySignature), + http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp), + http.CanonicalHeaderKey(sidecar.HeaderBodySHA256), + http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader): + return true + } + return false +} diff --git a/sidecar/server-demo/handler.go b/sidecar/server-demo/handler.go new file mode 100644 index 000000000..2fad340df --- /dev/null +++ b/sidecar/server-demo/handler.go @@ -0,0 +1,271 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar_demo + +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "net/url" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/sidecar" +) + +// proxyHandler handles HTTP requests from sandbox CLI instances. +type proxyHandler struct { + key []byte + cred *credential.CredentialProvider + appID string + brand core.LarkBrand + logger *log.Logger + forwardCl *http.Client + allowedHosts map[string]bool // target host allowlist derived from brand + allowedIDs map[string]bool // identity allowlist derived from strict mode +} + +// allowedAuthHeaders lists the only header names the sidecar will inject real +// tokens into. Limiting this prevents a compromised sandbox from signing a +// request with X-Lark-Proxy-Auth-Header: Cookie (or User-Agent / +// X-Forwarded-For / any X-* header) and having the real token smuggled into +// an upstream header that Lark ignores for auth but intermediate logs may +// capture — an indirect exfiltration path. +// +// These three are the only values the CLI interceptor ever emits +// (Authorization for OpenAPI, MCP-UAT/TAT for the MCP protocol), so anything +// else is by definition a misuse. +var allowedAuthHeaders = map[string]bool{ + "Authorization": true, + sidecar.HeaderMCPUAT: true, // X-Lark-MCP-UAT + sidecar.HeaderMCPTAT: true, // X-Lark-MCP-TAT +} + +func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // 0. Check protocol version. We reject rather than default so that an + // old client paired with a newer server (or vice versa) fails loudly + // instead of silently producing mismatched signatures. + version := r.Header.Get(sidecar.HeaderProxyVersion) + if version != sidecar.ProtocolV1 { + http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest) + return + } + + // 1. Verify timestamp + ts := r.Header.Get(sidecar.HeaderProxyTimestamp) + if ts == "" { + http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest) + return + } + + // 2. Read body and verify SHA256 + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + r.Body.Close() + + claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256) + if claimedSHA == "" { + http.Error(w, "missing "+sidecar.HeaderBodySHA256, http.StatusBadRequest) + return + } + actualSHA := sidecar.BodySHA256(body) + if claimedSHA != actualSHA { + http.Error(w, "body SHA256 mismatch", http.StatusBadRequest) + return + } + + // 3. Verify HMAC signature + //Enforce scheme=https and reject any path/query embedded in the target. + // The sandbox is untrusted: without this check it could send + // X-Lark-Proxy-Target: http://open.feishu.cn to force the injected real + // token out over cleartext HTTP, exposing it to any on-path attacker + // between the sidecar and upstream. + target := r.Header.Get(sidecar.HeaderProxyTarget) + if target == "" { + http.Error(w, "missing "+sidecar.HeaderProxyTarget, http.StatusBadRequest) + return + } + + pathAndQuery := r.URL.RequestURI() + targetHost, err := parseTarget(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)) + return + } + + // Identity and auth-header must be read before HMAC verification because + // both are covered by the canonical signing string. Defaulting either one + // server-side would let an attacker flip the injected token's identity or + // target header within the replay window without invalidating the sig. + identity := r.Header.Get(sidecar.HeaderProxyIdentity) + if identity == "" { + http.Error(w, "missing "+sidecar.HeaderProxyIdentity, http.StatusBadRequest) + return + } + authHeader := r.Header.Get(sidecar.HeaderProxyAuthHeader) + if authHeader == "" { + http.Error(w, "missing "+sidecar.HeaderProxyAuthHeader, http.StatusBadRequest) + return + } + + signature := r.Header.Get(sidecar.HeaderProxySignature) + if err := sidecar.Verify(h.key, sidecar.CanonicalRequest{ + Version: version, + Method: r.Method, + Host: targetHost, + PathAndQuery: pathAndQuery, + BodySHA256: claimedSHA, + Timestamp: ts, + Identity: identity, + AuthHeader: authHeader, + }, signature); err != nil { + http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized) + h.logger.Printf("REJECT method=%s path=%s reason=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err)) + return + } + + // 4. Validate target host against allowlist + if !h.allowedHosts[targetHost] { + http.Error(w, "target host not allowed: "+targetHost, http.StatusForbidden) + h.logger.Printf("REJECT method=%s path=%s reason=\"target host %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), targetHost) + return + } + + // 5. Validate identity + if !h.allowedIDs[identity] { + http.Error(w, "identity not allowed: "+identity, http.StatusForbidden) + h.logger.Printf("REJECT method=%s path=%s reason=\"identity %s not allowed by strict mode\"", r.Method, sanitizePath(pathAndQuery), identity) + return + } + + // 5.5 Validate auth-header (required — the client controls this value, + // and without an allowlist a compromised sandbox could direct the real + // token into arbitrary forwarded headers). + if !allowedAuthHeaders[authHeader] { + http.Error(w, "auth-header not allowed: "+authHeader, http.StatusForbidden) + h.logger.Printf("REJECT method=%s path=%s reason=\"auth-header %s not in allowlist\"", r.Method, sanitizePath(pathAndQuery), authHeader) + return + } + + // 6. Resolve real token + var tokenType credential.TokenType + switch identity { + case sidecar.IdentityUser: + tokenType = credential.TokenTypeUAT + default: + tokenType = credential.TokenTypeTAT + } + + tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{ + Type: tokenType, + AppID: h.appID, + }) + if err != nil { + http.Error(w, "failed to resolve token: "+err.Error(), http.StatusInternalServerError) + h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s error=%q", r.Method, sanitizePath(pathAndQuery), identity, sanitizeError(err)) + return + } + + // 7. Build forwarding request. Scheme is pinned to https here (not taken + // from the client-supplied target) so any future change to parseTarget + // cannot regress the cleartext-leak protection. + forwardURL := "https://" + targetHost + pathAndQuery + forwardReq, err := http.NewRequestWithContext(r.Context(), r.Method, forwardURL, bytes.NewReader(body)) + if err != nil { + http.Error(w, "failed to create forward request", http.StatusInternalServerError) + return + } + + // Copy non-proxy headers + for k, vs := range r.Header { + if isProxyHeader(k) { + continue + } + for _, v := range vs { + forwardReq.Header.Add(k, v) + } + } + + // Strip any client-supplied auth headers. The sidecar is the sole source + // of authentication material on the forwarded request; a client could + // otherwise smuggle an extra Authorization/MCP token alongside the one + // the sidecar injects below. + forwardReq.Header.Del("Authorization") + forwardReq.Header.Del(sidecar.HeaderMCPUAT) + forwardReq.Header.Del(sidecar.HeaderMCPTAT) + + // 8. Inject real token into the header the client committed to in the + // signature. Standard OpenAPI uses "Authorization: Bearer "; MCP + // uses "X-Lark-MCP-UAT: " or "X-Lark-MCP-TAT: ". + if authHeader == "Authorization" { + forwardReq.Header.Set("Authorization", "Bearer "+tokenResult.Token) + } else { + forwardReq.Header.Set(authHeader, tokenResult.Token) + } + + // 9. Forward request + resp, err := h.forwardCl.Do(forwardReq) + if err != nil { + http.Error(w, "forward request failed: "+err.Error(), http.StatusBadGateway) + h.logger.Printf("FORWARD_ERROR method=%s path=%s error=%q", r.Method, sanitizePath(pathAndQuery), sanitizeError(err)) + return + } + defer resp.Body.Close() + + // 10. Copy response back + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + + // 11. Audit log + 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 new file mode 100644 index 000000000..2bc2a20f2 --- /dev/null +++ b/sidecar/server-demo/handler_test.go @@ -0,0 +1,670 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar_demo + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + extcred "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/sidecar" +) + +// fakeExtProvider is a stub extcred.Provider for tests that returns a fixed token. +type fakeExtProvider struct { + token string +} + +func (f *fakeExtProvider) Name() string { return "fake" } +func (f *fakeExtProvider) ResolveAccount(ctx context.Context) (*extcred.Account, error) { + return nil, nil +} +func (f *fakeExtProvider) ResolveToken(ctx context.Context, req extcred.TokenSpec) (*extcred.Token, error) { + return &extcred.Token{Value: f.token, Source: "fake"}, nil +} + +func discardLogger() *log.Logger { + return log.New(io.Discard, "", 0) +} + +func newTestHandler(key []byte) *proxyHandler { + return &proxyHandler{ + key: key, + logger: discardLogger(), + forwardCl: &http.Client{}, + allowedHosts: map[string]bool{ + "open.feishu.cn": true, + "accounts.feishu.cn": true, + "mcp.feishu.cn": true, + }, + allowedIDs: map[string]bool{ + sidecar.IdentityUser: true, + sidecar.IdentityBot: true, + }, + } +} + +// 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 +// before calling ServeHTTP (and re-signing if they need the signature to +// remain valid after the mutation). +func signedReq(t *testing.T, key []byte, method, target, path string, body []byte) *http.Request { + t.Helper() + targetHost := target + if idx := strings.Index(target, "://"); idx >= 0 { + targetHost = target[idx+3:] + } + bodySHA := sidecar.BodySHA256(body) + ts := sidecar.Timestamp() + identity := sidecar.IdentityBot + authHeader := "Authorization" + sig := sidecar.Sign(key, sidecar.CanonicalRequest{ + Version: sidecar.ProtocolV1, + Method: method, + Host: targetHost, + PathAndQuery: path, + BodySHA256: bodySHA, + Timestamp: ts, + Identity: identity, + AuthHeader: authHeader, + }) + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + req := httptest.NewRequest(method, path, bodyReader) + req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) + req.Header.Set(sidecar.HeaderProxyTarget, target) + req.Header.Set(sidecar.HeaderProxyIdentity, identity) + req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader) + req.Header.Set(sidecar.HeaderBodySHA256, bodySHA) + req.Header.Set(sidecar.HeaderProxyTimestamp, ts) + req.Header.Set(sidecar.HeaderProxySignature, sig) + return req +} + +// resign recomputes the HMAC signature over the request's current proxy +// headers. Use this in tests that mutate a signed field (Identity, +// AuthHeader, Target host, etc.) after calling signedReq. +func resign(t *testing.T, key []byte, req *http.Request, body []byte) { + t.Helper() + target := req.Header.Get(sidecar.HeaderProxyTarget) + targetHost := target + if idx := strings.Index(target, "://"); idx >= 0 { + targetHost = target[idx+3:] + } + sig := sidecar.Sign(key, sidecar.CanonicalRequest{ + Version: req.Header.Get(sidecar.HeaderProxyVersion), + Method: req.Method, + Host: targetHost, + PathAndQuery: req.URL.RequestURI(), + BodySHA256: sidecar.BodySHA256(body), + Timestamp: req.Header.Get(sidecar.HeaderProxyTimestamp), + Identity: req.Header.Get(sidecar.HeaderProxyIdentity), + AuthHeader: req.Header.Get(sidecar.HeaderProxyAuthHeader), + }) + req.Header.Set(sidecar.HeaderProxySignature, sig) +} + +// TestProxyHandler_UnsupportedVersion verifies the handler rejects requests +// whose HeaderProxyVersion is absent or set to an unknown value. Kept in +// front so an old client paired with a newer server (or vice versa) surfaces +// a clear 400 instead of a misleading HMAC mismatch downstream. +func TestProxyHandler_UnsupportedVersion(t *testing.T) { + h := newTestHandler([]byte("key")) + for _, v := range []string{"", "v0", "v2"} { + req := httptest.NewRequest("GET", "/path", nil) + if v != "" { + req.Header.Set(sidecar.HeaderProxyVersion, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("version=%q: expected 400, got %d", v, w.Code) + } + } +} + +func TestProxyHandler_MissingTimestamp(t *testing.T) { + h := newTestHandler([]byte("key")) + req := httptest.NewRequest("GET", "/path", nil) + req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyHandler_MissingBodySHA(t *testing.T) { + h := newTestHandler([]byte("key")) + req := httptest.NewRequest("GET", "/path", nil) + req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) + req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp()) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyHandler_BadHMAC(t *testing.T) { + h := newTestHandler([]byte("real-key")) + + bodySHA := sidecar.BodySHA256(nil) + ts := sidecar.Timestamp() + + req := httptest.NewRequest("GET", "/path", nil) + req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) + req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn") + req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityBot) + req.Header.Set(sidecar.HeaderProxyAuthHeader, "Authorization") + req.Header.Set(sidecar.HeaderProxyTimestamp, ts) + req.Header.Set(sidecar.HeaderBodySHA256, bodySHA) + req.Header.Set(sidecar.HeaderProxySignature, "bad-signature") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestProxyHandler_BodySHA256Mismatch(t *testing.T) { + h := newTestHandler([]byte("key")) + + req := httptest.NewRequest("POST", "/path", bytes.NewReader([]byte("real body"))) + req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1) + req.Header.Set(sidecar.HeaderProxyTarget, "https://open.feishu.cn") + req.Header.Set(sidecar.HeaderProxyTimestamp, sidecar.Timestamp()) + req.Header.Set(sidecar.HeaderBodySHA256, sidecar.BodySHA256([]byte("different body"))) + req.Header.Set(sidecar.HeaderProxySignature, "whatever") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestProxyHandler_TargetNotAllowed(t *testing.T) { + key := []byte("test-key") + h := newTestHandler(key) + + req := signedReq(t, key, "GET", "https://evil.com", "/steal", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for disallowed host, got %d", w.Code) + } +} + +func TestProxyHandler_IdentityNotAllowed(t *testing.T) { + key := []byte("test-key") + h := newTestHandler(key) + // Restrict to bot only + h.allowedIDs = map[string]bool{sidecar.IdentityBot: true} + + req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil) + req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser) + resign(t, key, req, nil) // identity is signed; must re-sign after mutation + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for disallowed identity, got %d", w.Code) + } +} + +// TestParseTarget covers the per-shape rejections directly, without the +// surrounding HTTP plumbing. +func TestParseTarget(t *testing.T) { + cases := []struct { + name string + target string + wantErr bool + wantSub string // expected fragment of the error message + }{ + {name: "valid https", target: "https://open.feishu.cn", wantErr: false}, + {name: "valid https trailing slash", target: "https://open.feishu.cn/", wantErr: false}, + {name: "http downgrade", target: "http://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"}, + {name: "missing scheme", target: "open.feishu.cn", wantErr: true, wantSub: "scheme must be https"}, + {name: "ftp scheme", target: "ftp://open.feishu.cn", wantErr: true, wantSub: "scheme must be https"}, + {name: "empty", target: "", wantErr: true, wantSub: "scheme must be https"}, + {name: "empty host", target: "https://", wantErr: true, wantSub: "missing host"}, + {name: "with path", target: "https://open.feishu.cn/open-apis", wantErr: true, wantSub: "path not allowed"}, + {name: "with query", target: "https://open.feishu.cn?a=1", wantErr: true, wantSub: "query not allowed"}, + {name: "with fragment", target: "https://open.feishu.cn#frag", wantErr: true, wantSub: "fragment not allowed"}, + {name: "with userinfo", target: "https://attacker:pw@open.feishu.cn", wantErr: true, wantSub: "userinfo not allowed"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + host, err := parseTarget(tc.target) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got host=%q", host) + } + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("error %q should contain %q", err.Error(), tc.wantSub) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "open.feishu.cn" { + t.Errorf("host = %q, want %q", host, "open.feishu.cn") + } + }) + } +} + +// TestProxyHandler_RejectsNonHTTPSTarget verifies end-to-end that a +// compromised sandbox holding a valid PROXY_KEY cannot coerce the sidecar +// into forwarding real tokens over cleartext HTTP or to an unexpected path. +// The check must fire before HMAC verification so that the request is +// rejected even when the signature is technically valid. +func TestProxyHandler_RejectsNonHTTPSTarget(t *testing.T) { + key := []byte("test-key") + h := newTestHandler(key) + + cases := []struct { + name string + target string + }{ + {"http downgrade", "http://open.feishu.cn"}, + {"bare hostname", "open.feishu.cn"}, + {"ftp scheme", "ftp://open.feishu.cn"}, + {"target with path", "https://open.feishu.cn/open-apis/evil"}, + {"target with query", "https://open.feishu.cn?steal=1"}, + {"target with userinfo", "https://attacker:pw@open.feishu.cn"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Sign with a valid key against the malicious target — proves the + // scheme/shape check is not bypassed by signature legitimacy. + req := signedReq(t, key, "GET", tc.target, "/open-apis/im/v1/chats", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 for target %q, got %d (body: %s)", tc.target, w.Code, w.Body.String()) + } + }) + } +} + +// TestProxyHandler_RejectsIdentityReplay locks in C1 end-to-end: a captured +// bot-signed request whose identity header is flipped to user (or vice versa) +// must be rejected at HMAC verification, not silently served with the wrong +// token type. Without identity in the canonical string this returns 200. +func TestProxyHandler_RejectsIdentityReplay(t *testing.T) { + key := []byte("test-key") + h := newTestHandler(key) + + req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil) + // Attacker flips identity without touching signature. + req.Header.Set(sidecar.HeaderProxyIdentity, sidecar.IdentityUser) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("identity replay must fail signature verify (got %d, want 401): %s", + w.Code, w.Body.String()) + } +} + +// TestProxyHandler_RejectsAuthHeaderReplay is the companion: flipping +// X-Lark-Proxy-Auth-Header post-signature must invalidate the signature so +// an attacker cannot redirect the injected token into an unintended header. +func TestProxyHandler_RejectsAuthHeaderReplay(t *testing.T) { + key := []byte("test-key") + h := newTestHandler(key) + + req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil) + req.Header.Set(sidecar.HeaderProxyAuthHeader, "Cookie") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Errorf("auth-header replay must fail signature verify (got %d, want 401): %s", + w.Code, w.Body.String()) + } +} + +// TestProxyHandler_RejectsAuthHeaderNotInAllowlist pins the auth-header +// allowlist: even a correctly-signed request must be rejected if it asks +// the sidecar to inject the real token into an unintended header (e.g. +// Cookie / User-Agent / X-Forwarded-For). This closes the sidechannel +// where the real token ends up in headers that Lark ignores for auth but +// intermediate logs may capture. +func TestProxyHandler_RejectsAuthHeaderNotInAllowlist(t *testing.T) { + key := []byte("test-key") + h := newTestHandler(key) + + for _, bad := range []string{"Cookie", "User-Agent", "X-Forwarded-For", "X-Real-IP", "Set-Cookie"} { + t.Run(bad, func(t *testing.T) { + req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil) + req.Header.Set(sidecar.HeaderProxyAuthHeader, bad) + resign(t, key, req, nil) // auth-header is signed; must re-sign after override + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("authHeader=%q: expected 403, got %d (body: %s)", + bad, w.Code, w.Body.String()) + } + }) + } +} + +// TestProxyHandler_AcceptsAllowedAuthHeaders confirms the three protocol +// header names remain accepted after the allowlist is enforced. Uses +// newTestHandler which has no upstream forwarding set up, so reaching the +// forward step is proof the auth-header check passed. +func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) { + key := []byte("test-key") + + for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} { + t.Run(good, func(t *testing.T) { + // Use a handler with a real (fake) credential provider so we can + // distinguish auth-header reject (403) from later failures. + cred := credential.NewCredentialProvider( + []extcred.Provider{&fakeExtProvider{token: "real-token"}}, + nil, nil, nil, + ) + h := &proxyHandler{ + key: key, + cred: cred, + appID: "cli_test", + logger: discardLogger(), + forwardCl: &http.Client{}, + allowedHosts: map[string]bool{"open.feishu.cn": true}, + allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true}, + } + + req := signedReq(t, key, "GET", "https://open.feishu.cn", "/open-apis/test", nil) + req.Header.Set(sidecar.HeaderProxyAuthHeader, good) + resign(t, key, req, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + // Expect NOT 403 "auth-header not allowed" — the request will fail + // at forward (502 because open.feishu.cn isn't reachable without + // an actual upstream in tests), but it must get past our check. + if w.Code == http.StatusForbidden && strings.Contains(w.Body.String(), "auth-header not allowed") { + t.Errorf("authHeader=%q was rejected by allowlist: %s", good, w.Body.String()) + } + }) + } +} + +func TestRun_RejectsSelfProxy(t *testing.T) { + old, had := os.LookupEnv(envvars.CliAuthProxy) + os.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384") + defer func() { + if had { + os.Setenv(envvars.CliAuthProxy, old) + } else { + os.Unsetenv(envvars.CliAuthProxy) + } + }() + + err := run(context.Background(), "127.0.0.1:0", "/tmp/should-not-be-created.key", "", "") + if err == nil { + t.Fatal("expected error when AUTH_PROXY is set") + } + if !strings.Contains(err.Error(), envvars.CliAuthProxy) { + t.Errorf("error should mention %s, got: %v", envvars.CliAuthProxy, err) + } +} + +func TestForwardClient_RedirectStripsAuth(t *testing.T) { + redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth := r.Header.Get("Authorization"); auth != "" { + t.Errorf("Authorization leaked to redirect target: %s", auth) + } + w.WriteHeader(http.StatusOK) + })) + defer redirectTarget.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound) + })) + defer origin.Close() + + client := newForwardClient() + req, _ := http.NewRequest("GET", origin.URL+"/start", nil) + req.Header.Set("Authorization", "Bearer real-token") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() +} + +func TestForwardClient_RedirectStripsMCPHeaders(t *testing.T) { + redirectTarget := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if v := r.Header.Get(sidecar.HeaderMCPUAT); v != "" { + t.Errorf("X-Lark-MCP-UAT leaked to redirect target: %s", v) + } + if v := r.Header.Get(sidecar.HeaderMCPTAT); v != "" { + t.Errorf("X-Lark-MCP-TAT leaked to redirect target: %s", v) + } + w.WriteHeader(http.StatusOK) + })) + defer redirectTarget.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, redirectTarget.URL+"/redirected", http.StatusFound) + })) + defer origin.Close() + + client := newForwardClient() + req, _ := http.NewRequest("POST", origin.URL+"/mcp", nil) + req.Header.Set(sidecar.HeaderMCPUAT, "real-uat-token") + req.Header.Set(sidecar.HeaderMCPTAT, "real-tat-token") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + resp.Body.Close() +} + +// TestProxyHandler_StripsClientSuppliedAuthHeaders verifies that the sidecar +// is the sole source of auth headers on the forwarded request. A malicious +// sandbox client must not be able to smuggle an Authorization/MCP header that +// rides along with the sidecar-injected real token. +func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) { + const realToken = "real-tenant-access-token" + + // Capture what the upstream receives after sidecar forwarding. + // TLS is required because parseTarget rejects non-https targets. + var captured http.Header + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + // Strip "https://" prefix to get host:port (matches what the handler sees). + upstreamHost := strings.TrimPrefix(upstream.URL, "https://") + + cred := credential.NewCredentialProvider( + []extcred.Provider{&fakeExtProvider{token: realToken}}, + nil, nil, nil, + ) + + key := []byte("test-key") + h := &proxyHandler{ + key: key, + cred: cred, + appID: "cli_test", + logger: discardLogger(), + forwardCl: upstream.Client(), // trusts the httptest CA + allowedHosts: map[string]bool{upstreamHost: true}, + allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true}, + } + + cases := []struct { + name string + proxyAuthHeader string // which header sidecar should inject into + wantInjectedHeader string // the header the real token ends up in + wantInjectedValue string + wantStrippedHeaders []string + }{ + { + name: "inject Authorization, strip MCP attacker headers", + proxyAuthHeader: "Authorization", + wantInjectedHeader: "Authorization", + wantInjectedValue: "Bearer " + realToken, + wantStrippedHeaders: []string{sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT}, + }, + { + name: "inject MCP UAT, strip Authorization attacker header", + proxyAuthHeader: sidecar.HeaderMCPUAT, + wantInjectedHeader: sidecar.HeaderMCPUAT, + wantInjectedValue: realToken, + wantStrippedHeaders: []string{"Authorization", sidecar.HeaderMCPTAT}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + captured = nil + + req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil) + req.Header.Set(sidecar.HeaderProxyAuthHeader, tc.proxyAuthHeader) + resign(t, key, req, nil) // auth-header is signed; re-sign after override + + // Attacker smuggles all three possible auth headers with bogus values. + req.Header.Set("Authorization", "Bearer attacker-token") + req.Header.Set(sidecar.HeaderMCPUAT, "attacker-uat") + req.Header.Set(sidecar.HeaderMCPTAT, "attacker-tat") + + // Non-auth headers should still pass through. + req.Header.Set("X-Custom-Header", "keep-me") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 from upstream, got %d; body=%s", w.Code, w.Body.String()) + } + if captured == nil { + t.Fatal("upstream handler was not invoked") + } + + // Injected header contains the real token (not the attacker value). + if got := captured.Get(tc.wantInjectedHeader); got != tc.wantInjectedValue { + t.Errorf("%s = %q, want %q", tc.wantInjectedHeader, got, tc.wantInjectedValue) + } + + // All other auth headers must be stripped. + for _, h := range tc.wantStrippedHeaders { + if got := captured.Get(h); got != "" { + t.Errorf("%s should be stripped, got %q", h, got) + } + } + + // Non-auth headers still forwarded. + if got := captured.Get("X-Custom-Header"); got != "keep-me" { + t.Errorf("X-Custom-Header = %q, want %q", got, "keep-me") + } + }) + } +} + +func TestBuildAllowedHosts(t *testing.T) { + feishu := struct{ Open, Accounts, MCP string }{ + "https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn", + } + lark := struct{ Open, Accounts, MCP string }{ + "https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com", + } + hosts := buildAllowedHosts(feishu, lark) + // feishu hosts + if !hosts["open.feishu.cn"] { + t.Error("expected open.feishu.cn in allowlist") + } + if !hosts["mcp.feishu.cn"] { + t.Error("expected mcp.feishu.cn in allowlist") + } + // lark hosts + if !hosts["open.larksuite.com"] { + t.Error("expected open.larksuite.com in allowlist") + } + if !hosts["mcp.larksuite.com"] { + t.Error("expected mcp.larksuite.com in allowlist") + } + // evil host + if hosts["evil.com"] { + t.Error("evil.com should not be in allowlist") + } +} + +func TestSanitizePath(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"/open-apis/im/v1/messages?receive_id_type=chat_id", "/open-apis/im/v1/messages"}, + {"/open-apis/calendar/v4/events", "/open-apis/calendar/v4/events"}, + {"/open-apis/docx/v1/documents/doxcnABCD1234/blocks", "/open-apis/docx/v1/documents/:id/blocks"}, + {"/open-apis/im/v1/chats/oc_abcdef12345678/members", "/open-apis/im/v1/chats/:id/members"}, + {"/path?secret=abc", "/path"}, + } + for _, tt := range tests { + if got := sanitizePath(tt.input); got != tt.want { + t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestLooksLikeID(t *testing.T) { + tests := []struct { + seg string + want bool + }{ + {"doxcnABCD1234", true}, // doc token + {"oc_abcdef12345678", true}, // chat ID + {"v1", false}, // API version + {"messages", false}, // route keyword + {"open-apis", false}, // route prefix + {"ab1", false}, // too short + } + for _, tt := range tests { + if got := looksLikeID(tt.seg); got != tt.want { + t.Errorf("looksLikeID(%q) = %v, want %v", tt.seg, got, tt.want) + } + } +} + +func TestSanitizeError(t *testing.T) { + short := fmt.Errorf("short error") + if got := sanitizeError(short); got != "short error" { + t.Errorf("got %q", got) + } + + longMsg := make([]byte, 300) + for i := range longMsg { + longMsg[i] = 'x' + } + long := fmt.Errorf("%s", string(longMsg)) + got := sanitizeError(long) + if len(got) > 210 { + t.Errorf("expected truncation, got %d chars", len(got)) + } + if !bytes.HasSuffix([]byte(got), []byte("...")) { + t.Errorf("expected '...' suffix, got %q", got[len(got)-10:]) + } +} diff --git a/sidecar/server-demo/main.go b/sidecar/server-demo/main.go new file mode 100644 index 000000000..1197b5145 --- /dev/null +++ b/sidecar/server-demo/main.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build authsidecar_demo + +// Command sidecar-server-demo is a reference implementation of a sidecar +// auth proxy server. It is NOT production-ready — integrators should +// implement their own server conforming to the wire protocol defined in +// github.com/larksuite/cli/sidecar. +// +// The demo reuses the lark-cli credential pipeline (keychain + config) to +// resolve real tokens, so it only works on a machine that has been +// configured with `lark-cli auth login`. +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/envvars" + "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/sidecar" +) + +func main() { + listen := flag.String("listen", sidecar.DefaultListenAddr, "listen address (host:port)") + keyFile := flag.String("key-file", defaultKeyFile(), "path to write the HMAC key") + logFile := flag.String("log-file", "", "audit log file (stderr if empty)") + profile := flag.String("profile", "", "lark-cli profile name (empty = active profile)") + flag.Parse() + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := run(ctx, *listen, *keyFile, *logFile, *profile); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func defaultKeyFile() string { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, ".lark-sidecar", "proxy.key") + } + return "/tmp/lark-sidecar/proxy.key" +} + +func run(ctx context.Context, listen, keyFile, logFile, profile string) error { + // Reject self-proxy: if this process inherited AUTH_PROXY, the sidecar + // credential provider would activate and return sentinel tokens instead + // of real ones, breaking the "trusted side holds real credentials" premise. + if v := os.Getenv(envvars.CliAuthProxy); v != "" { + return fmt.Errorf("%s is set in this environment (%s); unset it before starting the sidecar server", envvars.CliAuthProxy, v) + } + if listen == "" { + return fmt.Errorf("invalid --listen address: empty") + } + + // Generate HMAC key (32 bytes = 256 bits) and write it to disk (0600). + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return fmt.Errorf("failed to generate HMAC key: %v", err) + } + keyHex := hex.EncodeToString(keyBytes) + + keyDir := filepath.Dir(keyFile) + if err := vfs.MkdirAll(keyDir, 0700); err != nil { + return fmt.Errorf("failed to create key directory: %v", err) + } + if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil { + return fmt.Errorf("failed to write key file: %v", err) + } + + // Audit logger: file or stderr. + var auditLogger *log.Logger + if logFile != "" { + f, err := vfs.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + defer f.Close() + auditLogger = log.New(f, "", log.LstdFlags) + } else { + auditLogger = log.New(os.Stderr, "[audit] ", log.LstdFlags) + } + + // Reuse the lark-cli credential pipeline. A production implementation + // would likely source credentials from a secrets manager instead. + factory := cmdutil.NewDefault(cmdutil.InvocationContext{Profile: profile}) + cfg, err := factory.Config() + if err != nil { + return fmt.Errorf("failed to load config: %v", err) + } + + listener, err := net.Listen("tcp", listen) + if err != nil { + return fmt.Errorf("failed to listen on %s: %v", listen, err) + } + defer listener.Close() + + allowedHosts := buildAllowedHosts( + core.ResolveEndpoints(core.BrandFeishu), + core.ResolveEndpoints(core.BrandLark), + ) + allowedIDs := buildAllowedIdentities(cfg) + + handler := &proxyHandler{ + key: []byte(keyHex), + cred: factory.Credential, + appID: cfg.AppID, + brand: cfg.Brand, + logger: auditLogger, + forwardCl: newForwardClient(), + allowedHosts: allowedHosts, + allowedIDs: allowedIDs, + } + + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + go func() { + <-ctx.Done() + auditLogger.Println("shutting down...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + auditLogger.Printf("shutdown error: %v", err) + } + }() + + keyPrefix := keyHex + if len(keyPrefix) > 8 { + keyPrefix = keyPrefix[:8] + } + proxyURL := "http://" + listen + fmt.Fprintf(os.Stderr, "Auth sidecar listening on %s\n", proxyURL) + fmt.Fprintf(os.Stderr, "HMAC key prefix: %s\n", keyPrefix) + fmt.Fprintf(os.Stderr, "Full key written to %s (mode 0600)\n", keyFile) + fmt.Fprintf(os.Stderr, "\nSet in sandbox:\n") + fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAuthProxy, proxyURL) + fmt.Fprintf(os.Stderr, " export %s=\"\"\n", envvars.CliProxyKey, keyFile) + fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliAppID, cfg.AppID) + fmt.Fprintf(os.Stderr, " export %s=%q\n", envvars.CliBrand, string(cfg.Brand)) + + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("sidecar server exited unexpectedly: %v", err) + } + return nil +}