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
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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user