mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:20:00 +08:00
Relax the auth-sidecar proxy address policy so a remote central sidecar reachable over TLS can be used, while keeping existing same-host plaintext behavior unchanged. - ValidateProxyAddr: allow https:// to any host (cross-machine); http:// and bare host:port stay same-host only; userinfo/path/query/fragment remain rejected. - Add ProxyScheme and route the interceptor URL rewrite through the configured scheme (https for remote, http for same-host). ProxyScheme parses the address so a mixed-case HTTPS:// cannot silently downgrade to plaintext HTTP. - Update LARKSUITE_CLI_AUTH_PROXY doc and server-demo README for the new policy; refresh the package comment. - Tests: case-insensitive scheme, IPv6 https, https userinfo rejection, query/fragment rejection, ProxyHost https forms, and end-to-end interceptor scheme selection.
187 lines
6.5 KiB
Go
187 lines
6.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build authsidecar
|
|
|
|
// Package sidecar provides a transport interceptor for the auth sidecar
|
|
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
|
// URL), all outgoing requests are rewritten to the sidecar address. The
|
|
// interceptor strips placeholder credentials, injects proxy headers, and
|
|
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
|
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
|
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
|
package sidecar
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/extension/transport"
|
|
"github.com/larksuite/cli/internal/envvars"
|
|
"github.com/larksuite/cli/sidecar"
|
|
)
|
|
|
|
// Provider implements transport.Provider for the sidecar mode.
|
|
type Provider struct{}
|
|
|
|
func (p *Provider) Name() string { return "sidecar" }
|
|
|
|
// ResolveInterceptor returns a SidecarInterceptor when sidecar mode is active.
|
|
// Returns nil when sidecar mode is disabled or the proxy address is invalid;
|
|
// in the latter case a warning is emitted to stderr and requests fall back to
|
|
// the non-sidecar transport path (where the credential layer will typically
|
|
// block them for lack of a valid account).
|
|
func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
|
|
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
|
if proxyAddr == "" {
|
|
return nil
|
|
}
|
|
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
|
fmt.Fprintf(os.Stderr, "WARNING: invalid %s, sidecar interceptor disabled: %v\n", envvars.CliAuthProxy, err)
|
|
return nil
|
|
}
|
|
key := os.Getenv(envvars.CliProxyKey)
|
|
return &Interceptor{
|
|
key: []byte(key),
|
|
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
|
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
|
}
|
|
}
|
|
|
|
// Interceptor rewrites requests for the sidecar proxy.
|
|
type Interceptor struct {
|
|
key []byte // HMAC signing key
|
|
sidecarHost string // sidecar host[:port] for URL rewriting
|
|
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
|
}
|
|
|
|
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
|
// sentinel token. Requests without a sentinel token (e.g. pre-signed download
|
|
// URLs) are passed through unmodified.
|
|
//
|
|
// Supports two auth patterns:
|
|
// - Standard OpenAPI: Authorization: Bearer <sentinel>
|
|
// - MCP protocol: X-Lark-MCP-UAT/TAT: <sentinel>
|
|
func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response, err error) {
|
|
identity, authHeader := detectSentinel(req)
|
|
if identity == "" {
|
|
return nil // not a sidecar-managed request, pass through
|
|
}
|
|
|
|
// 1. Buffer the body first, before mutating any request state. A partial
|
|
// read would sign a truncated body and cause a misleading HMAC mismatch
|
|
// on the sidecar side; bail out early and let the request fall through
|
|
// unmodified so the credential layer can surface an actionable error.
|
|
var bodyBytes []byte
|
|
if req.Body != nil {
|
|
var err error
|
|
bodyBytes, err = io.ReadAll(req.Body)
|
|
_ = req.Body.Close() // release original body (fd/pipe/etc.) after buffering
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "WARNING: sidecar interceptor failed to read request body: %v\n", err)
|
|
return nil
|
|
}
|
|
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|
if req.GetBody != nil {
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(bodyBytes)), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Save original target (scheme://host)
|
|
originalScheme := "https"
|
|
if req.URL.Scheme != "" {
|
|
originalScheme = req.URL.Scheme
|
|
}
|
|
originalHost := req.URL.Host
|
|
req.Header.Set(sidecar.HeaderProxyTarget, originalScheme+"://"+originalHost)
|
|
|
|
// 3. Set identity and tell sidecar which header to inject real token into
|
|
req.Header.Set(sidecar.HeaderProxyIdentity, identity)
|
|
req.Header.Set(sidecar.HeaderProxyAuthHeader, authHeader)
|
|
|
|
// 4. Strip placeholder auth header(s)
|
|
req.Header.Del("Authorization")
|
|
req.Header.Del(sidecar.HeaderMCPUAT)
|
|
req.Header.Del(sidecar.HeaderMCPTAT)
|
|
|
|
bodySHA := sidecar.BodySHA256(bodyBytes)
|
|
req.Header.Set(sidecar.HeaderBodySHA256, bodySHA)
|
|
|
|
pathAndQuery := req.URL.RequestURI()
|
|
ts := sidecar.Timestamp()
|
|
// Cover identity and authHeader in the signature so an on-path attacker
|
|
// within the replay window cannot flip the injected token's identity or
|
|
// redirect the token into a different header.
|
|
sig := sidecar.Sign(i.key, sidecar.CanonicalRequest{
|
|
Version: sidecar.ProtocolV1,
|
|
Method: req.Method,
|
|
Host: originalHost,
|
|
PathAndQuery: pathAndQuery,
|
|
BodySHA256: bodySHA,
|
|
Timestamp: ts,
|
|
Identity: identity,
|
|
AuthHeader: authHeader,
|
|
})
|
|
req.Header.Set(sidecar.HeaderProxyVersion, sidecar.ProtocolV1)
|
|
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
|
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
|
|
|
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
|
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
|
scheme := i.sidecarScheme
|
|
if scheme == "" {
|
|
scheme = "http"
|
|
}
|
|
req.URL.Scheme = scheme
|
|
req.URL.Host = i.sidecarHost
|
|
|
|
return nil // no post-hook needed
|
|
}
|
|
|
|
// detectSentinel checks both standard Authorization and MCP auth headers for
|
|
// sentinel tokens. Returns the identity ("user"/"bot") and the header name
|
|
// that carried the sentinel.
|
|
//
|
|
// Returns ("", "") when the request carries no sentinel token — typically
|
|
// requests that require no auth (e.g. pre-signed download URLs where the
|
|
// token is embedded in the URL query parameters).
|
|
func detectSentinel(req *http.Request) (identity, authHeader string) {
|
|
// Check standard Authorization: Bearer <sentinel>
|
|
if auth := req.Header.Get("Authorization"); auth != "" {
|
|
token := strings.TrimPrefix(auth, "Bearer ")
|
|
switch token {
|
|
case sidecar.SentinelUAT:
|
|
return sidecar.IdentityUser, "Authorization"
|
|
case sidecar.SentinelTAT:
|
|
return sidecar.IdentityBot, "Authorization"
|
|
}
|
|
}
|
|
// Check MCP headers: X-Lark-MCP-UAT/TAT: <sentinel>
|
|
if v := req.Header.Get(sidecar.HeaderMCPUAT); v == sidecar.SentinelUAT {
|
|
return sidecar.IdentityUser, sidecar.HeaderMCPUAT
|
|
}
|
|
if v := req.Header.Get(sidecar.HeaderMCPTAT); v == sidecar.SentinelTAT {
|
|
return sidecar.IdentityBot, sidecar.HeaderMCPTAT
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func init() {
|
|
proxyAddr := os.Getenv(envvars.CliAuthProxy)
|
|
if proxyAddr == "" {
|
|
return
|
|
}
|
|
if err := sidecar.ValidateProxyAddr(proxyAddr); err != nil {
|
|
fmt.Fprintf(os.Stderr, "WARNING: ignoring invalid %s: %v\n", envvars.CliAuthProxy, err)
|
|
return
|
|
}
|
|
transport.Register(&Provider{})
|
|
}
|