chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+88
View File
@@ -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)
}
+300
View File
@@ -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)
}
})
}
}
+198
View File
@@ -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
}
+203
View File
@@ -0,0 +1,203 @@
# 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 <HOME>/.lark-sidecar/proxy.key \
--log-file <HOME>/.lark-sidecar/audit.log
```
### Flags
| Flag | Default | Purpose |
| --- | --- | --- |
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
| `--key-file` | `<HOME>/.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="<read from /Users/alice/.lark-sidecar/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://<host> + 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 |
## See also
- [server-multi-tenant-demo](../server-multi-tenant-demo/) — extends this demo
with per-client HMAC key isolation, OAuth device-flow login, and persistent
client → user mapping for multi-tenant deployments
+44
View File
@@ -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
}
+51
View File
@@ -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
}
+51
View File
@@ -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
}
+271
View File
@@ -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 <token>"; MCP
// uses "X-Lark-MCP-UAT: <token>" or "X-Lark-MCP-TAT: <token>".
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://<host>" 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
}
+670
View File
@@ -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:])
}
}
+167
View File
@@ -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(nil, 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=\"<read from %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
}
+281
View File
@@ -0,0 +1,281 @@
# Multi-Tenant Sidecar Server Demo
> ⚠️ **This is a demo.** For production deployment, implement your own sidecar
> server conforming to the wire protocol in `github.com/larksuite/cli/sidecar`.
## Problem
Organizations often manage **multiple Lark/Feishu apps** (e.g. one per
department, one per product line), each with its own `app_id` and `app_secret`.
These credentials must never be exposed to end-user environments (CI runners,
developer sandboxes, containerized workspaces). At the same time, when multiple
users share the same sidecar infrastructure, their Feishu identities must be
strictly isolated — user A must never accidentally operate as user B.
The single-tenant [server-demo](../server-demo/) solves the credential-hiding
problem for **one app with one user**. This multi-tenant demo extends it to
support:
1. **Multiple apps** — run one sidecar instance per app; each instance holds
its own `app_id` / `app_secret` and listens on a separate port. Clients
choose which app to use by pointing `LARKSUITE_CLI_AUTH_PROXY` to the
corresponding port.
2. **Per-client identity isolation** — each client environment gets a unique
HMAC key. The sidecar identifies request origin by matching the HMAC
signature and injects the correct user's token. No fallback to other
users' tokens.
3. **Self-service user login** — management endpoints let each client initiate
an OAuth device-flow login to bind their own Feishu identity, without
exposing `app_secret` to the client.
## Typical deployment
```text
Trusted Host
┌──────────────────────────────────────────────┐
│ sidecar instance A (port 16384) │
│ app_id=cli_aaa app_secret=*** │
│ keys/proxy.key keys/alice.key keys/bob… │
│ │
│ sidecar instance B (port 16385) │
│ app_id=cli_bbb app_secret=*** │
│ keys/proxy.key keys/charlie.key ... │
└─────────────┬────────────────────────────────┘
│ same machine (loopback / docker bridge)
┌─────────────┴────────────────────────────────┐
│ Client sandbox (container / CI runner) │
│ │
│ LARKSUITE_CLI_AUTH_PROXY=http://host:16384 │
│ LARKSUITE_CLI_PROXY_KEY=<contents of │
│ alice.key> │
│ LARKSUITE_CLI_APP_ID=cli_aaa │
│ LARKSUITE_CLI_BRAND=feishu │
│ │
│ $ lark api GET /open-apis/... --as user │
│ → sidecar matches alice.key │
│ → injects alice's Feishu user token │
└──────────────────────────────────────────────┘
```
**Key points:**
- `app_id` and `app_secret` live only on the trusted host — clients only
know `app_id` (needed for the CLI's credential pipeline) and their own
HMAC key.
- Each sidecar instance binds one app. Multiple apps = multiple instances
on different ports.
- Clients select which app to use by choosing which sidecar port to connect
to (via `LARKSUITE_CLI_AUTH_PROXY`).
## Architecture
```text
┌──────────────────────────────────────────────────────┐
│ Sidecar Server │
│ │
│ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ Shared Key │ │ Per-Client Keys │ │
│ │ (proxy.key) │ │ alice.key, bob.key, ... │ │
│ └──────┬──────┘ └──────────────┬───────────────┘ │
│ │ management plane │ data plane │
│ ▼ ▼ │
│ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │ Auth Bridge │ │ Proxy Handler │ │
│ │ login/poll/ │ │ HMAC verify → identify │ │
│ │ status │ │ client → inject user token │ │
│ └─────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
```
**Dual-key design:**
- **Management plane** (login flow): all clients use the shared `proxy.key`.
This allows any client to initiate login and query status without needing
individual key files pre-provisioned.
- **Data plane** (API proxy): each client uses its own `{name}.key` for HMAC
signing. The sidecar identifies the client by matching which key verifies
the request signature, then injects that client's bound user token.
## Build
```bash
go build -tags authsidecar_multi_tenant_demo \
-o sidecar-multi-tenant-demo \
./sidecar/server-multi-tenant-demo/
```
## Server setup
### 1. Configure the Lark app (trusted side only)
```bash
lark-cli config init --new # set app_id / app_secret
```
### 2. Prepare the keys directory
```text
keys/
├── proxy.key # shared key (auto-generated on first run)
├── alice.key # client "alice" — generate with: openssl rand -hex 32 > alice.key
├── bob.key # client "bob"
└── charlie.key # client "charlie"
```
- Each file contains a 64-character hex string (32 bytes).
- Filename stem (without `.key`) becomes the client identity.
- `proxy.key` is excluded from client key scanning.
- Keys are auto-rescanned on cache miss — add a new `.key` file and the next
unrecognized request will trigger a rescan; no restart needed.
- Duplicate key values and shared-key collisions are rejected with a warning.
### 3. Start the server
```bash
./sidecar-multi-tenant-demo \
--listen 127.0.0.1:16384 \
--key-file /path/to/keys/proxy.key \
--keys-dir /path/to/keys/ \
--log-file /path/to/audit.log
```
| Flag | Default | Purpose |
| --- | --- | --- |
| `--listen` | `127.0.0.1:16384` | Address to bind the HTTP listener |
| `--key-file` | `~/.lark-sidecar/proxy.key` | Shared HMAC key path (created if absent) |
| `--keys-dir` | *(parent of `--key-file`)* | Directory containing per-client `*.key` files |
| `--log-file` | *(stderr)* | Audit log output path |
| `--profile` | *(active profile)* | lark-cli profile name for credential lookup |
## Client setup
**No changes to `lark-cli` itself are required.** The standard sidecar env
vars are all that's needed — the multi-tenant isolation is entirely
server-side.
### Required environment variables
```bash
# Point to the sidecar instance for the desired app
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
# Client-specific HMAC key (data-plane identity)
export LARKSUITE_CLI_PROXY_KEY="$(cat /path/to/keys/alice.key)"
# Must match the app configured on the sidecar instance
export LARKSUITE_CLI_APP_ID="cli_xxx"
# feishu or lark
export LARKSUITE_CLI_BRAND="feishu"
```
### Multi-app switching (multiple sidecar instances)
When the server operator runs multiple sidecar instances (one per app), clients
switch between apps by changing `LARKSUITE_CLI_AUTH_PROXY` to point to the
appropriate port:
```bash
# App A (e.g. "Marketing" app)
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16384"
export LARKSUITE_CLI_APP_ID="cli_marketing_app"
# App B (e.g. "Engineering" app)
export LARKSUITE_CLI_AUTH_PROXY="http://127.0.0.1:16385"
export LARKSUITE_CLI_APP_ID="cli_engineering_app"
```
A client-side helper script can present these as a menu (e.g. "Select
company"), reading from a local config file that maps app names to ports.
The sidecar itself does not implement app selection — it is one instance per
app by design.
### User login flow
Once the env vars are set, the client authenticates via the management
endpoints. A helper script (or manual `curl`) calls:
1. **Login**: `POST /_sidecar/auth/login` with `{"client_id": "alice"}`
returns a device code and verification URL.
2. **User opens the URL in a browser** and authorizes the app.
3. **Poll**: `POST /_sidecar/auth/poll` with `{"device_code": "...", "client_id": "alice"}`
blocks until authorization completes.
4. **Status**: `POST /_sidecar/auth/status` with `{"client_id": "alice"}`
returns the bound user name and token status.
All management requests are signed with the **shared `proxy.key`** (not the
client-specific key). The `client_id` in the body tells the sidecar which
client→user mapping to update.
After login, `lark-cli` commands (`lark api ...`, `lark doc ...`, etc.) work
immediately — the sidecar injects the correct user token based on the
client's HMAC key, with no additional configuration needed.
### Example: end-to-end workflow
```bash
# 1. Server operator generates a key for a new client
openssl rand -hex 32 > /path/to/keys/alice.key
# 2. Client environment is configured (e.g. in .bashrc or container init)
export LARKSUITE_CLI_AUTH_PROXY="http://host.docker.internal:16384"
export LARKSUITE_CLI_PROXY_KEY="$(cat /path/to/keys/alice.key)"
export LARKSUITE_CLI_APP_ID="cli_xxx"
export LARKSUITE_CLI_BRAND="feishu"
# 3. Client logs in (one-time)
# (using a helper script that calls the management endpoints)
lark-auth login
# 4. Client uses lark-cli as normal — identity is automatically resolved
lark api GET /open-apis/authen/v1/user_info --as user
# → returns alice's Feishu identity, not another user's
```
## Management endpoints
| Endpoint | Method | Body | Purpose |
| --- | --- | --- | --- |
| `/_sidecar/auth/login` | POST | `{"client_id": "...", "domains": [...]}` | Start OAuth device-flow |
| `/_sidecar/auth/poll` | POST | `{"device_code": "...", "client_id": "..."}` | Poll for completion |
| `/_sidecar/auth/status` | POST | `{"client_id": "..."}` | Query status and mapping |
All management requests require HMAC signing with the shared `proxy.key`.
The HMAC covers method, path, timestamp, and body SHA-256 — see
`verifyManagementHMAC` in `auth_bridge.go` for the canonical string format.
## Design decisions
1. **HMAC key as client identity** — the key is the existing trust anchor.
Using it for identification introduces no new trust assumptions and
prevents a malicious client from spoofing another client's identity
(unlike a header-based approach).
2. **No fallback on unmapped clients** — this is authentication. Silently
falling back to another user's token is a security violation. Unmapped
clients receive an explicit error prompting them to log in.
3. **One sidecar instance per app** — keeps `app_secret` scoping simple and
avoids cross-app token confusion. Multi-app support is achieved by running
multiple instances on different ports.
4. **Proxy.key reuse across restarts** — when multiple sidecar instances start
concurrently, they all write to the same key file. The last writer wins,
leaving other instances with stale in-memory keys. Reusing the existing
key eliminates this race.
## Source layout
| File | Purpose |
| --- | --- |
| `main.go` | Entry point: flag parsing, key loading, server lifecycle |
| `handler.go` | `proxyHandler.ServeHTTP` — multi-key HMAC verification and request forwarding |
| `auth_bridge.go` | Management endpoints: login, poll, status, user mapping persistence |
| `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 |
## See also
- [server-demo](../server-demo/) — single-tenant minimal implementation
- [`sidecar` package](https://pkg.go.dev/github.com/larksuite/cli/sidecar) — wire protocol
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_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
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_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
}
@@ -0,0 +1,530 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/vfs"
)
// authBridge handles /_sidecar/auth/* management endpoints.
// Supports multi-user token isolation: each client environment gets its own
// Feishu identity via a clientName → feishuOpenId mapping.
//
// Identity chain: PROXY_KEY → clientName → feishuOpenId → keychain token
type authBridge struct {
key []byte
appID string
appSecret string
brand core.LarkBrand
cred *credential.CredentialProvider
logger *log.Logger
httpCl *http.Client
mu sync.Mutex
pendingPolls map[string]context.CancelFunc
// clientName → feishuOpenId (protected by mu)
userMap map[string]string
mapFile string
}
func newAuthBridge(key []byte, appID, appSecret string, brand core.LarkBrand, cred *credential.CredentialProvider, logger *log.Logger) *authBridge {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
mapFile := ""
if configDir != "" {
mapFile = filepath.Join(configDir, "client_user_map.json")
}
ab := &authBridge{
key: key,
appID: appID,
appSecret: appSecret,
brand: brand,
cred: cred,
logger: logger,
httpCl: &http.Client{Timeout: 30 * time.Second},
pendingPolls: make(map[string]context.CancelFunc),
userMap: make(map[string]string),
mapFile: mapFile,
}
ab.loadUserMap()
return ab
}
func (ab *authBridge) loadUserMap() {
if ab.mapFile == "" {
return
}
data, err := vfs.ReadFile(ab.mapFile)
if err != nil {
return
}
var m map[string]string
if json.Unmarshal(data, &m) == nil && m != nil {
ab.userMap = m
}
}
func (ab *authBridge) saveUserMap() {
if ab.mapFile == "" {
return
}
data, err := json.MarshalIndent(ab.userMap, "", " ")
if err != nil {
ab.logger.Printf("AUTH_BRIDGE_ERROR action=save_user_map error=%q", err.Error())
return
}
if err := vfs.WriteFile(ab.mapFile, data, 0600); err != nil {
ab.logger.Printf("AUTH_BRIDGE_ERROR action=save_user_map error=%q", err.Error())
}
}
// verifyManagementHMAC checks a simplified HMAC for management endpoints.
// Canonical string: "sidecar-mgmt\n<method>\n<path>\n<timestamp>\n<body_sha256>"
func (ab *authBridge) verifyManagementHMAC(r *http.Request, body []byte) error {
ts := r.Header.Get("X-Sidecar-Timestamp")
sig := r.Header.Get("X-Sidecar-Signature")
bodySha := r.Header.Get("X-Sidecar-Body-SHA256")
if ts == "" || sig == "" || bodySha == "" {
return fmt.Errorf("missing required headers")
}
tsVal, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp")
}
drift := math.Abs(float64(time.Now().Unix() - tsVal))
if drift > 60 {
return fmt.Errorf("timestamp drift %.0fs exceeds limit", drift)
}
actualSha := sha256Hex(body)
if bodySha != actualSha {
return fmt.Errorf("body SHA256 mismatch")
}
canonical := "sidecar-mgmt\n" + r.Method + "\n" + r.URL.Path + "\n" + ts + "\n" + bodySha
mac := hmac.New(sha256.New, ab.key)
mac.Write([]byte(canonical))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sig)) {
return fmt.Errorf("HMAC signature mismatch")
}
return nil
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// ServeHTTP routes management API requests.
func (ab *authBridge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
if err != nil {
jsonError(w, http.StatusBadRequest, "failed to read body")
return
}
r.Body.Close()
if err := ab.verifyManagementHMAC(r, body); err != nil {
jsonError(w, http.StatusUnauthorized, "HMAC verification failed: "+err.Error())
ab.logger.Printf("AUTH_BRIDGE_REJECT path=%s reason=%q", r.URL.Path, err.Error())
return
}
switch r.URL.Path {
case "/_sidecar/auth/login":
ab.handleLogin(w, r, body)
case "/_sidecar/auth/poll":
ab.handlePoll(w, r, body)
case "/_sidecar/auth/status":
ab.handleStatus(w, r, body)
default:
jsonError(w, http.StatusNotFound, "unknown management endpoint")
}
}
// parseClientID extracts the client identifier from a JSON body.
func parseClientID(body []byte) string {
var raw struct {
ClientID string `json:"client_id"`
}
if len(body) > 0 {
_ = json.Unmarshal(body, &raw)
}
return raw.ClientID
}
// handleLogin initiates a device-flow OAuth login.
func (ab *authBridge) handleLogin(w http.ResponseWriter, _ *http.Request, body []byte) {
var req struct {
Scope string `json:"scope"`
Domains []string `json:"domains"`
}
if len(body) > 0 {
_ = json.Unmarshal(body, &req)
}
clientID := parseClientID(body)
scope := req.Scope
if scope == "" {
scope = loadCachedScopes()
}
if scope == "" {
scope = "offline_access"
}
ab.logger.Printf("AUTH_BRIDGE_LOGIN_SCOPE scope_count=%d domains=%v client=%s",
len(strings.Fields(scope)), req.Domains, clientID)
authResp, err := larkauth.RequestDeviceAuthorization(
ab.httpCl, ab.appID, ab.appSecret, ab.brand, scope, io.Discard,
)
if err != nil {
jsonError(w, http.StatusBadGateway, "device authorization failed: "+err.Error())
ab.logger.Printf("AUTH_BRIDGE_ERROR action=login error=%q", err.Error())
return
}
ab.logger.Printf("AUTH_BRIDGE_LOGIN device_code_prefix=%s expires_in=%d",
truncate(authResp.DeviceCode, 12), authResp.ExpiresIn)
resp := map[string]interface{}{
"ok": true,
"verification_url": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"interval": authResp.Interval,
}
jsonOK(w, resp)
}
// handlePoll polls the device-flow token endpoint.
// Binds the resulting feishu identity to the client on success.
func (ab *authBridge) handlePoll(w http.ResponseWriter, r *http.Request, body []byte) {
var req struct {
DeviceCode string `json:"device_code"`
}
if err := json.Unmarshal(body, &req); err != nil || req.DeviceCode == "" {
jsonError(w, http.StatusBadRequest, "device_code is required")
return
}
clientID := parseClientID(body)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
ab.mu.Lock()
if oldCancel, ok := ab.pendingPolls[req.DeviceCode]; ok {
oldCancel()
}
ab.pendingPolls[req.DeviceCode] = cancel
ab.mu.Unlock()
defer func() {
ab.mu.Lock()
delete(ab.pendingPolls, req.DeviceCode)
ab.mu.Unlock()
}()
result := larkauth.PollDeviceToken(
ctx, ab.httpCl, ab.appID, ab.appSecret, ab.brand,
req.DeviceCode, 5, 600, io.Discard,
)
if !result.OK {
resp := map[string]interface{}{
"ok": false,
"error": result.Error,
"msg": result.Message,
}
jsonOK(w, resp)
ab.logger.Printf("AUTH_BRIDGE_POLL_FAIL device_code_prefix=%s error=%q",
truncate(req.DeviceCode, 12), result.Message)
return
}
if result.Token == nil {
jsonError(w, http.StatusInternalServerError, "token response was nil")
return
}
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
AppId: ab.appID,
AccessToken: result.Token.AccessToken,
RefreshToken: result.Token.RefreshToken,
ExpiresAt: now + int64(result.Token.ExpiresIn)*1000,
RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000,
Scope: result.Token.Scope,
GrantedAt: now,
}
ep := core.ResolveEndpoints(ab.brand)
openID, userName, err := fetchUserInfoDirect(ab.httpCl, ep.Open, result.Token.AccessToken)
if err != nil {
ab.logger.Printf("AUTH_BRIDGE_WARN action=user_info error=%q", err.Error())
jsonError(w, http.StatusBadGateway, "login succeeded but failed to get user info: "+err.Error())
return
}
storedToken.UserOpenId = openID
if err := larkauth.SetStoredToken(storedToken); err != nil {
jsonError(w, http.StatusInternalServerError, "failed to store token: "+err.Error())
return
}
if err := addUserToConfig(ab.appID, openID, userName); err != nil {
ab.logger.Printf("AUTH_BRIDGE_WARN action=sync_config error=%q", err.Error())
}
if clientID != "" {
ab.mu.Lock()
ab.userMap[clientID] = openID
ab.saveUserMap()
ab.mu.Unlock()
ab.logger.Printf("AUTH_BRIDGE_MAP client=%s -> feishu=%s (%s)",
clientID, openID, userName)
}
ab.logger.Printf("AUTH_BRIDGE_LOGIN_OK user=%s open_id=%s scope_count=%d client=%s",
userName, openID, len(strings.Fields(result.Token.Scope)), clientID)
resp := map[string]interface{}{
"ok": true,
"user_name": userName,
"open_id": openID,
}
jsonOK(w, resp)
}
// handleStatus returns current auth status.
// Accepts client_id in body for client-specific mapping.
func (ab *authBridge) handleStatus(w http.ResponseWriter, _ *http.Request, body []byte) {
clientID := parseClientID(body)
multi, err := core.LoadMultiAppConfig()
if err != nil {
jsonError(w, http.StatusInternalServerError, "failed to load config: "+err.Error())
return
}
var users []map[string]interface{}
for _, app := range multi.Apps {
if app.AppId != ab.appID {
continue
}
for _, u := range app.Users {
stored := larkauth.GetStoredToken(ab.appID, u.UserOpenId)
status := "unknown"
if stored != nil {
status = larkauth.TokenStatus(stored)
}
users = append(users, map[string]interface{}{
"user_name": u.UserName,
"user_open_id": u.UserOpenId,
"token_status": status,
})
}
}
resp := map[string]interface{}{
"ok": true,
"users": users,
}
if clientID != "" {
ab.mu.Lock()
mappedOpenID := ab.userMap[clientID]
ab.mu.Unlock()
resp["client_id"] = clientID
resp["mapped_open_id"] = mappedOpenID
if mappedOpenID != "" {
stored := larkauth.GetStoredToken(ab.appID, mappedOpenID)
if stored != nil {
resp["mapped_status"] = larkauth.TokenStatus(stored)
for _, u := range users {
if u["user_open_id"] == mappedOpenID {
resp["mapped_user_name"] = u["user_name"]
break
}
}
} else {
resp["mapped_status"] = "no_token"
}
} else {
resp["mapped_status"] = "not_mapped"
}
}
jsonOK(w, resp)
}
// resolveUserTokenByClient resolves a UAT for a specific client environment.
// Returns an error if the client has no user mapping — the user must
// run the login flow first. No fallback to other users' tokens.
func (ab *authBridge) resolveUserTokenByClient(clientName string) (string, error) {
ab.mu.Lock()
openID := ab.userMap[clientName]
ab.mu.Unlock()
if openID == "" {
ab.logger.Printf("AUTH_BRIDGE_REJECT_NO_MAPPING client=%s", clientName)
return "", fmt.Errorf("client %q has no user mapping; run the login flow to authorize", clientName)
}
ab.logger.Printf("AUTH_BRIDGE_RESOLVE client=%s feishu=%s", clientName, openID)
opts := larkauth.UATCallOptions{
UserOpenId: openID,
AppId: ab.appID,
AppSecret: ab.appSecret,
Domain: ab.brand,
}
token, err := larkauth.GetValidAccessToken(ab.httpCl, opts)
if err != nil {
return "", fmt.Errorf("failed to resolve token for user %s: %v", openID, err)
}
return token, nil
}
func addUserToConfig(appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return err
}
for i := range multi.Apps {
if multi.Apps[i].AppId != appID {
continue
}
found := false
for j := range multi.Apps[i].Users {
if multi.Apps[i].Users[j].UserOpenId == openID {
multi.Apps[i].Users[j].UserName = userName
found = true
break
}
}
if !found {
multi.Apps[i].Users = append(multi.Apps[i].Users, core.AppUser{
UserOpenId: openID,
UserName: userName,
})
}
return core.SaveMultiAppConfig(multi)
}
return fmt.Errorf("app %s not found in config", appID)
}
func fetchUserInfoDirect(client *http.Client, openBase, accessToken string) (openID, name string, err error) {
u := openBase + "/open-apis/authen/v1/user_info"
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", err
}
var result struct {
Code int `json:"code"`
Data struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
} `json:"data"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", "", fmt.Errorf("parse user_info response: %w", err)
}
if result.Code != 0 {
return "", "", fmt.Errorf("user_info API error: [%d] %s", result.Code, result.Msg)
}
return result.Data.OpenID, result.Data.Name, nil
}
func jsonOK(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"error": msg,
})
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
func loadCachedScopes() string {
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
if configDir == "" {
return ""
}
dir := filepath.Join(configDir, "cache", "auth_login_scopes")
entries, err := vfs.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := vfs.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var doc struct {
RequestedScope string `json:"requested_scope"`
}
if json.Unmarshal(data, &doc) == nil && doc.RequestedScope != "" {
return doc.RequestedScope
}
}
return ""
}
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_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
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path/filepath"
"strings"
"sync"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/vfs"
"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
authBridge *authBridge
// Per-client key isolation: keyHex → clientName.
// Data-plane requests are signed with a client-specific key;
// the matched key determines which client (and thus which user
// token) to use. Protected by ckMu.
ckMu sync.RWMutex
clientKeys map[string]clientKeyEntry
keysDir string // directory to scan for *.key files (excluding proxy.key)
}
type clientKeyEntry struct {
key []byte
clientName string
}
// loadClientKeys scans keysDir for *.key files (excluding the shared
// proxy.key) and populates the clientKeys map. The filename stem (without
// .key) becomes the client identity. No naming convention is enforced.
// Safe to call multiple times (e.g. on cache miss).
func (h *proxyHandler) loadClientKeys() {
if h.keysDir == "" {
return
}
entries, err := vfs.ReadDir(h.keysDir)
if err != nil {
h.logger.Printf("KEYS_SCAN_ERROR dir=%s error=%q", h.keysDir, err.Error())
return
}
sharedKeyHex := string(h.key)
newKeys := make(map[string]clientKeyEntry)
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".key") {
continue
}
clientName := strings.TrimSuffix(name, ".key")
if clientName == "" || clientName == "proxy" {
continue
}
data, err := vfs.ReadFile(filepath.Join(h.keysDir, name))
if err != nil {
continue
}
keyHex := strings.TrimSpace(string(data))
if len(keyHex) != 64 {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"key length %d, expected 64\"", name, len(keyHex))
continue
}
if keyHex == sharedKeyHex {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"collides with shared proxy key\"", name)
continue
}
if existing, ok := newKeys[keyHex]; ok {
h.logger.Printf("KEYS_SCAN_SKIP file=%s reason=\"duplicate key, already loaded for client %s\"", name, existing.clientName)
continue
}
newKeys[keyHex] = clientKeyEntry{key: []byte(keyHex), clientName: clientName}
}
h.ckMu.Lock()
h.clientKeys = newKeys
h.ckMu.Unlock()
if len(newKeys) > 0 {
names := make([]string, 0, len(newKeys))
for _, e := range newKeys {
names = append(names, e.clientName)
}
h.logger.Printf("KEYS_LOADED count=%d clients=%v", len(newKeys), names)
}
}
// verifyWithClientKeys tries each client key to verify the HMAC.
// Returns the client name on success, or empty string + error if none match.
func (h *proxyHandler) verifyWithClientKeys(cr sidecar.CanonicalRequest, signature string) (string, error) {
h.ckMu.RLock()
keys := h.clientKeys
h.ckMu.RUnlock()
for _, entry := range keys {
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
return entry.clientName, nil
}
}
// Cache miss: rescan keys directory and retry once
h.loadClientKeys()
h.ckMu.RLock()
keys = h.clientKeys
h.ckMu.RUnlock()
for _, entry := range keys {
if err := sidecar.Verify(entry.key, cr, signature); err == nil {
return entry.clientName, nil
}
}
return "", fmt.Errorf("no client key matched")
}
// allowedAuthHeaders lists the only header names the sidecar will inject real
// tokens into.
var allowedAuthHeaders = map[string]bool{
"Authorization": true,
sidecar.HeaderMCPUAT: true,
sidecar.HeaderMCPTAT: true,
}
func (h *proxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Route management endpoints to authBridge (different HMAC scheme)
if len(r.URL.Path) > 10 && r.URL.Path[:10] == "/_sidecar/" {
if h.authBridge != nil {
h.authBridge.ServeHTTP(w, r)
} else {
http.Error(w, "auth bridge not configured", http.StatusNotImplemented)
}
return
}
start := time.Now()
// 0. Check protocol version
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
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 := 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)
cr := sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: pathAndQuery,
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}
// Try the primary (shared) key first, then per-client keys.
// matchedClient is empty when using the shared key.
var matchedClient string
if err := sidecar.Verify(h.key, cr, signature); err != nil {
client, clientErr := h.verifyWithClientKeys(cr, signature)
if clientErr != 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), "no key matched")
return
}
matchedClient = client
}
// 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
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
// UAT (user identity): per-client isolation via matched PROXY_KEY.
// TAT (bot identity): shared credential provider (app-level).
var resolvedToken string
if identity == sidecar.IdentityUser && h.authBridge != nil {
token, err := h.authBridge.resolveUserTokenByClient(matchedClient)
if err != nil {
http.Error(w, "failed to resolve user token: "+err.Error(), http.StatusInternalServerError)
h.logger.Printf("TOKEN_ERROR method=%s path=%s identity=%s client=%s error=%q",
r.Method, sanitizePath(pathAndQuery), identity, matchedClient, sanitizeError(err))
return
}
resolvedToken = token
} else {
tokenResult, err := h.cred.ResolveToken(r.Context(), credential.TokenSpec{
Type: credential.TokenTypeTAT,
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
}
resolvedToken = tokenResult.Token
}
// 7. Build forwarding request
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
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
forwardReq.Header.Add(k, v)
}
}
forwardReq.Header.Del("Authorization")
forwardReq.Header.Del(sidecar.HeaderMCPUAT)
forwardReq.Header.Del(sidecar.HeaderMCPTAT)
// 8. Inject real token
if authHeader == "Authorization" {
forwardReq.Header.Set("Authorization", "Bearer "+resolvedToken)
} else {
forwardReq.Header.Set(authHeader, resolvedToken)
}
// 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
clientTag := ""
if matchedClient != "" {
clientTag = " client=" + matchedClient
}
h.logger.Printf("FORWARD method=%s path=%s identity=%s status=%d duration=%s%s",
r.Method, sanitizePath(pathAndQuery), identity, resp.StatusCode, time.Since(start).Round(time.Millisecond), clientTag)
}
// parseTarget validates X-Lark-Proxy-Target and returns the host portion.
func parseTarget(target string) (host string, err error) {
u, perr := url.Parse(target)
if perr != nil {
return "", fmt.Errorf("parse: %w", perr)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}
@@ -0,0 +1,878 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_demo
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)
// 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. A local
// TLS test server stands in for the upstream so the test is fully offline.
func TestProxyHandler_AcceptsAllowedAuthHeaders(t *testing.T) {
key := []byte("test-key")
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
upstreamHost := strings.TrimPrefix(upstream.URL, "https://")
for _, good := range []string{"Authorization", sidecar.HeaderMCPUAT, sidecar.HeaderMCPTAT} {
t.Run(good, func(t *testing.T) {
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{token: "real-token"}},
nil, nil, nil,
)
h := &proxyHandler{
key: key,
cred: cred,
appID: "cli_test",
logger: discardLogger(),
forwardCl: upstream.Client(),
allowedHosts: map[string]bool{upstreamHost: true},
allowedIDs: map[string]bool{sidecar.IdentityUser: true, sidecar.IdentityBot: true},
}
req := signedReq(t, key, "GET", "https://"+upstreamHost, "/open-apis/test", nil)
req.Header.Set(sidecar.HeaderProxyAuthHeader, good)
resign(t, key, req, nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("authHeader=%q: expected 200, got %d body=%s", good, w.Code, w.Body.String())
}
})
}
}
func TestRun_RejectsSelfProxy(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keyPath := filepath.Join(t.TempDir(), "proxy.key")
err := run(context.Background(), "127.0.0.1:0", keyPath, "", "", "")
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 := core.Endpoints{
Open: "https://open.feishu.cn", Accounts: "https://accounts.feishu.cn", MCP: "https://mcp.feishu.cn",
}
lark := core.Endpoints{
Open: "https://open.larksuite.com", Accounts: "https://accounts.larksuite.com", MCP: "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:])
}
}
// ---------- Multi-tenant tests ----------
func writeKeyFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestLoadClientKeys_SkipsSharedKeyCollision(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32) // 64 hex chars
aliceKey := strings.Repeat("bb", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", aliceKey)
writeKeyFile(t, dir, "evil.key", sharedKey) // same as shared key
var logBuf bytes.Buffer
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: log.New(&logBuf, "", 0),
}
h.loadClientKeys()
h.ckMu.RLock()
defer h.ckMu.RUnlock()
if len(h.clientKeys) != 1 {
t.Fatalf("expected 1 client key (alice), got %d", len(h.clientKeys))
}
for _, entry := range h.clientKeys {
if entry.clientName != "alice" {
t.Errorf("expected client alice, got %s", entry.clientName)
}
}
if !strings.Contains(logBuf.String(), "KEYS_SCAN_SKIP") || !strings.Contains(logBuf.String(), "collides with shared proxy key") {
t.Errorf("expected KEYS_SCAN_SKIP log for shared key collision, got: %s", logBuf.String())
}
}
func TestLoadClientKeys_SkipsDuplicateKeyHex(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32)
dupeKey := strings.Repeat("cc", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", dupeKey)
writeKeyFile(t, dir, "bob.key", dupeKey) // duplicate of alice
var logBuf bytes.Buffer
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: log.New(&logBuf, "", 0),
}
h.loadClientKeys()
h.ckMu.RLock()
defer h.ckMu.RUnlock()
if len(h.clientKeys) != 1 {
t.Fatalf("expected 1 client key (first loaded), got %d", len(h.clientKeys))
}
if !strings.Contains(logBuf.String(), "KEYS_SCAN_SKIP") || !strings.Contains(logBuf.String(), "duplicate key") {
t.Errorf("expected KEYS_SCAN_SKIP log for duplicate key, got: %s", logBuf.String())
}
}
func TestLoadClientKeys_SkipsProxyAndNonKeyFiles(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", strings.Repeat("bb", 32))
writeKeyFile(t, dir, "notes.txt", "not a key")
if err := os.MkdirAll(filepath.Join(dir, "subdir.key"), 0755); err != nil {
t.Fatal(err)
}
var logBuf bytes.Buffer
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: log.New(&logBuf, "", 0),
}
h.loadClientKeys()
h.ckMu.RLock()
defer h.ckMu.RUnlock()
if len(h.clientKeys) != 1 {
t.Fatalf("expected 1 client key (alice), got %d", len(h.clientKeys))
}
}
func TestVerifyWithClientKeys_MatchesCorrectClient(t *testing.T) {
dir := t.TempDir()
sharedKey := strings.Repeat("aa", 32)
aliceKey := strings.Repeat("bb", 32)
bobKey := strings.Repeat("cc", 32)
writeKeyFile(t, dir, "proxy.key", sharedKey)
writeKeyFile(t, dir, "alice.key", aliceKey)
writeKeyFile(t, dir, "bob.key", bobKey)
h := &proxyHandler{
key: []byte(sharedKey),
keysDir: dir,
clientKeys: make(map[string]clientKeyEntry),
logger: discardLogger(),
}
h.loadClientKeys()
cr := sidecar.CanonicalRequest{
Version: sidecar.ProtocolV1,
Method: "GET",
Host: "open.feishu.cn",
PathAndQuery: "/test",
BodySHA256: sidecar.BodySHA256(nil),
Timestamp: sidecar.Timestamp(),
Identity: sidecar.IdentityBot,
AuthHeader: "Authorization",
}
// Sign with alice's key
aliceSig := sidecar.Sign([]byte(aliceKey), cr)
client, err := h.verifyWithClientKeys(cr, aliceSig)
if err != nil {
t.Fatalf("expected alice key to verify, got error: %v", err)
}
if client != "alice" {
t.Errorf("expected client=alice, got %q", client)
}
// Sign with bob's key
bobSig := sidecar.Sign([]byte(bobKey), cr)
client, err = h.verifyWithClientKeys(cr, bobSig)
if err != nil {
t.Fatalf("expected bob key to verify, got error: %v", err)
}
if client != "bob" {
t.Errorf("expected client=bob, got %q", client)
}
// Sign with unknown key
unknownKey := strings.Repeat("dd", 32)
unknownSig := sidecar.Sign([]byte(unknownKey), cr)
client, err = h.verifyWithClientKeys(cr, unknownSig)
if err == nil {
t.Errorf("expected error for unknown key, got client=%q", client)
}
if client != "" {
t.Errorf("expected empty client for unknown key, got %q", client)
}
}
func TestUserMap_RoundTripPersistence(t *testing.T) {
dir := t.TempDir()
mapFile := filepath.Join(dir, "client_user_map.json")
ab := &authBridge{
userMap: make(map[string]string),
mapFile: mapFile,
logger: discardLogger(),
}
// Initially empty
ab.loadUserMap()
if len(ab.userMap) != 0 {
t.Fatalf("expected empty map, got %v", ab.userMap)
}
// Populate and save
ab.userMap["alice"] = "ou_alice_open_id_123"
ab.userMap["bob"] = "ou_bob_open_id_456"
ab.saveUserMap()
// Verify file contents
data, err := os.ReadFile(mapFile)
if err != nil {
t.Fatalf("failed to read map file: %v", err)
}
var saved map[string]string
if err := json.Unmarshal(data, &saved); err != nil {
t.Fatalf("failed to parse saved map: %v", err)
}
if saved["alice"] != "ou_alice_open_id_123" || saved["bob"] != "ou_bob_open_id_456" {
t.Errorf("saved map mismatch: %v", saved)
}
// Create new instance and load — simulates restart
ab2 := &authBridge{
userMap: make(map[string]string),
mapFile: mapFile,
logger: discardLogger(),
}
ab2.loadUserMap()
if ab2.userMap["alice"] != "ou_alice_open_id_123" {
t.Errorf("after reload, alice=%q, want ou_alice_open_id_123", ab2.userMap["alice"])
}
if ab2.userMap["bob"] != "ou_bob_open_id_456" {
t.Errorf("after reload, bob=%q, want ou_bob_open_id_456", ab2.userMap["bob"])
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar_multi_tenant_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"
"strings"
"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/validate"
"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")
keysDir := flag.String("keys-dir", "", "directory containing per-client *.key files for identity isolation (defaults to key-file's parent dir)")
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, *keysDir, *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, keysDir, logFile, profile string) error {
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")
}
if _, err := validate.SafeInputPath(keyFile); err != nil {
return fmt.Errorf("invalid --key-file path: %w", err)
}
if logFile != "" {
if _, err := validate.SafeInputPath(logFile); err != nil {
return fmt.Errorf("invalid --log-file path: %w", err)
}
}
if keysDir != "" {
if _, err := validate.SafeInputPath(keysDir); err != nil {
return fmt.Errorf("invalid --keys-dir path: %w", err)
}
}
// Reuse existing key if present; generate a new one only on first run.
keyDir := filepath.Dir(keyFile)
if err := vfs.MkdirAll(keyDir, 0700); err != nil {
return fmt.Errorf("failed to create key directory: %v", err)
}
var keyHex string
if existing, err := vfs.ReadFile(keyFile); err == nil && len(strings.TrimSpace(string(existing))) == 64 {
keyHex = strings.TrimSpace(string(existing))
} else {
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)
if err := vfs.WriteFile(keyFile, []byte(keyHex), 0600); err != nil {
return fmt.Errorf("failed to write key file: %v", err)
}
}
// Default keysDir to the parent directory of keyFile
if keysDir == "" {
keysDir = keyDir
}
// Audit logger
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)
}
factory := cmdutil.NewDefault(nil, 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)
ab := newAuthBridge([]byte(keyHex), cfg.AppID, cfg.AppSecret, cfg.Brand, factory.Credential, auditLogger)
handler := &proxyHandler{
key: []byte(keyHex),
cred: factory.Credential,
appID: cfg.AppID,
brand: cfg.Brand,
logger: auditLogger,
forwardCl: newForwardClient(),
allowedHosts: allowedHosts,
allowedIDs: allowedIDs,
authBridge: ab,
keysDir: keysDir,
}
handler.loadClientKeys()
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, "Client keys dir: %s\n", keysDir)
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=\"<read from %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
}