Files
larksuite-cli/sidecar/server-demo/handler_test.go
sang-neo03 5943a20e2b Feat/auth sidecar proxy (#532)
* feat(sidecar): add sidecar proxy for sandbox credential isolation

Keep real secrets (app_secret, access_token) out of sandbox environments.
CLI instances inside sandboxes connect to a trusted sidecar process via
HTTP; the sidecar verifies HMAC-signed requests and injects real tokens
before forwarding to the Lark API.

Key components:

- `auth proxy` subcommand to start the sidecar server (build tag: authsidecar)
- Noop credential provider returns sentinel tokens in sidecar mode
- Transport interceptor rewrites requests to sidecar with HMAC signature
- Env provider yields to sidecar provider when AUTH_PROXY is set
- Supports both feishu and lark brand endpoints

* feat(sidecar): implement priority ordering for credential providers

* feat(sidecar): strip client-supplied auth headers and improve shutdown logging

* feat(sidecar): buffer request body to prevent HMAC mismatches on read errors

* feat(sidecar): fix CI

* refactor(sidecar): publish protocol package and move server to reference demo

  The sidecar server is no longer shipped as a `lark-cli auth proxy`
  subcommand. Instead, the CLI provides only the standard sidecar *client*
  (via `-tags authsidecar`), while the wire-protocol utilities are exposed
  as a public package for integrators to implement their own server.

  Changes:
  - Move `internal/sidecar/` → `sidecar/` so external integrators can
    import HMAC signing, headers, sentinels and address validators.
  - Remove `cmd/auth/proxy.go`, `proxy_stub.go`, `proxy_test.go` and the
    conditional registration in `cmd/auth/auth.go`.
  - Add `sidecar/server-demo/` — a reference server implementation behind
    the `authsidecar_demo` build tag. It reuses the lark-cli credential
    pipeline for local development; production integrators are expected
    to replace the credential layer with their own secrets source.
  - Update all internal imports from `internal/sidecar` to `sidecar`.

  Rationale:
  - Each integrator has different secrets management / HA / multi-tenant
    requirements, so a one-size-fits-all server doesn't belong in the
    shipped CLI.
  - Keeping the client in-tree guarantees all sandbox-side code stays
    protocol-compatible without a second repo to sync.
  - The public `sidecar/` package pins the wire protocol as a stable
    contract third-party servers must conform to.

  Build matrix after this change:
  - `go build`                         → standard CLI, no sidecar code
  - `go build -tags authsidecar`       → CLI + sidecar client
  - `go build -tags authsidecar_demo \
      ./sidecar/server-demo/`          → reference server binary

  No production users are affected today because the server was not yet
  released; existing sidecar-client users are unchanged.

* feat(sidecar): close 5 pre-release security gaps
  - Server: enforce https-only target (no path/query/userinfo), pin
    forwardURL to https:// — blocks cleartext token leak
  - Protocol v1: canonical now covers version/identity/auth-header,
    blocks identity-flip replay within drift window
  - Client: ValidateProxyAddr requires loopback or same-host alias,
    rejects userinfo and https (interceptor is http-only); cross-machine
    is out of scope
  - Build: non-authsidecar builds exit(2) when AUTH_PROXY is set,
    preventing silent fallback to env credentials
  - Demo: whitelist auth-header to Authorization / X-Lark-MCP-{UAT,TAT},
    blocks token injection into Cookie / UA / X-Forwarded-For exfil paths
2026-04-20 20:24:51 +08:00

671 lines
23 KiB
Go

// 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:])
}
}