mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
* 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
199 lines
7.4 KiB
Go
199 lines
7.4 KiB
Go
// 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
|
|
}
|