Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions extension/transport/sidecar/interceptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
//go:build authsidecar

// Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP 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 plain HTTP.
// 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 (
Expand Down Expand Up @@ -46,15 +47,17 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
}
key := os.Getenv(envvars.CliProxyKey)
return &Interceptor{
key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr),
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
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
Expand Down Expand Up @@ -130,8 +133,13 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig)

// 5. Rewrite URL to route through sidecar
req.URL.Scheme = "http"
// 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
Expand Down
50 changes: 50 additions & 0 deletions extension/transport/sidecar/interceptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ package sidecar

import (
"bytes"
"context"
"errors"
"io"
"net/http"
"testing"

"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
)

Expand Down Expand Up @@ -97,6 +99,54 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
}
}

// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
// rewrites the request to https://<remote-host>, while still preserving the
// original target and signing the request.
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}

req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)

interceptor.PreRoundTrip(req)

if req.URL.Scheme != "https" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
}
if req.URL.Host != "sidecar.mycorp.com" {
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
}
// Original target still preserved for the sidecar to forward upstream.
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Request is still signed.
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
}

// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
// (mixed-case) https proxy address must produce an interceptor that rewrites to
// https, never silently downgrading a remote sidecar to plaintext http.
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
t.Setenv(envvars.CliProxyKey, "key")

ic := (&Provider{}).ResolveInterceptor(context.Background())
si, ok := ic.(*Interceptor)
if !ok || si == nil {
t.Fatalf("expected *Interceptor, got %T", ic)
}
if si.sidecarScheme != "https" {
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
}
if si.sidecarHost != "sidecar.mycorp.com" {
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
}
}

func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}

Expand Down
2 changes: 1 addition & 1 deletion internal/envvars/envvars.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const (
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"

// Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar

// Content safety scanning mode
Expand Down
107 changes: 97 additions & 10 deletions sidecar/hmac_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ func TestValidateProxyAddr(t *testing.T) {
"http://gateway.docker.internal:16384",
// trailing slash is tolerated
"http://127.0.0.1:8080/",
// https: any valid host (including remote, cross-machine) is allowed
"https://127.0.0.1:16384",
"https://sidecar.mycorp.com",
"https://sidecar.mycorp.com:8443",
"https://sidecar.corp.internal:443/",
}
for _, addr := range valid {
if err := ValidateProxyAddr(addr); err != nil {
Expand Down Expand Up @@ -242,6 +247,8 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
"http://user@127.0.0.1:16384",
"http://user:pass@127.0.0.1:16384",
"http://127.0.0.1@attacker.com:16384",
"https://x@evil.com",
"https://user:pass@sidecar.mycorp.com",
} {
err := ValidateProxyAddr(addr)
if err == nil {
Expand All @@ -259,23 +266,99 @@ func TestValidateProxyAddr_RejectsUserinfo(t *testing.T) {
}
}

// 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) {
// TestValidateProxyAddr_HTTPSAllowed pins the contract: https addresses are
// accepted, including a remote sidecar on another machine. TLS provides
// confidentiality over the network and the HMAC signature provides
// integrity/auth, so cross-machine https is supported.
func TestValidateProxyAddr_HTTPSAllowed(t *testing.T) {
for _, addr := range []string{
"https://127.0.0.1:16384",
"https://127.0.0.1:16384", // same-host over TLS
"https://sidecar.corp.internal:443",
"https://sidecar.mycorp.com", // remote, no explicit port
"https://sidecar.mycorp.com:8443",
} {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
}
}
}

// TestValidateProxyAddr_HTTPRemoteRejected: plaintext http to a non-same-host
// address stays rejected — a remote sidecar must use https.
func TestValidateProxyAddr_HTTPRemoteRejected(t *testing.T) {
for _, addr := range []string{
"http://sidecar.mycorp.com",
"http://sidecar.mycorp.com:8080",
"http://10.0.0.1:16384",
} {
err := ValidateProxyAddr(addr)
if err == nil {
t.Errorf("ValidateProxyAddr(%q): expected error, got nil", addr)
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
continue
}
if !strings.Contains(err.Error(), "https") {
t.Errorf("ValidateProxyAddr(%q): error should mention https, got: %v", addr, err)
msg := err.Error()
if !strings.Contains(msg, "https") && !strings.Contains(msg, "same-host") && !strings.Contains(msg, "loopback") {
t.Errorf("ValidateProxyAddr(%q): error should point to https/same-host, got: %v", addr, err)
}
}
}

// TestProxyScheme: scheme is https only for https:// addresses, http otherwise.
// Case-insensitive: HTTPS:// must resolve to https, otherwise a remote sidecar
// would silently downgrade to plaintext http (see ProxyScheme doc).
func TestProxyScheme(t *testing.T) {
tests := map[string]string{
"https://sidecar.mycorp.com": "https",
"https://127.0.0.1:16384": "https",
"http://127.0.0.1:16384": "http",
"127.0.0.1:16384": "http",
// case-insensitive scheme
"HTTPS://sidecar.mycorp.com": "https",
"Https://sidecar.mycorp.com": "https",
"HtTp://127.0.0.1:16384": "http",
}
for in, want := range tests {
if got := ProxyScheme(in); got != want {
t.Errorf("ProxyScheme(%q) = %q, want %q", in, got, want)
}
}
}

// TestValidateProxyAddr_SchemeCaseInsensitive: mixed-case scheme must follow the
// same policy as lower-case — HTTPS accepted (remote allowed), HTTP remote
// rejected — so case can't be used to bypass the plaintext same-host rule.
func TestValidateProxyAddr_SchemeCaseInsensitive(t *testing.T) {
for _, addr := range []string{"HTTPS://sidecar.mycorp.com", "Https://sidecar.corp.internal:443"} {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
}
}
for _, addr := range []string{"HtTp://sidecar.mycorp.com", "HTTP://10.0.0.1:16384"} {
if err := ValidateProxyAddr(addr); err == nil {
t.Errorf("ValidateProxyAddr(%q): expected rejection (http remote), got nil", addr)
}
}
}

// TestValidateProxyAddr_IPv6HTTPS pins IPv6 https forms.
func TestValidateProxyAddr_IPv6HTTPS(t *testing.T) {
for _, addr := range []string{"https://[::1]:443", "https://[::1]"} {
if err := ValidateProxyAddr(addr); err != nil {
t.Errorf("ValidateProxyAddr(%q): expected accepted, got: %v", addr, err)
}
}
}

// TestValidateProxyAddr_RejectsQueryFragment: a proxy address must not carry a
// query or fragment, for either scheme.
func TestValidateProxyAddr_RejectsQueryFragment(t *testing.T) {
for _, addr := range []string{
"https://sidecar.mycorp.com?x=1",
"https://sidecar.mycorp.com#frag",
"http://127.0.0.1:16384?x=1",
} {
if err := ValidateProxyAddr(addr); err == nil {
t.Errorf("ValidateProxyAddr(%q): expected rejection, got nil", addr)
}
}
}
Expand All @@ -289,6 +372,10 @@ func TestProxyHost(t *testing.T) {
{"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
// https forms (remote sidecar)
{"https://sidecar.mycorp.com", "sidecar.mycorp.com"},
{"https://sidecar.mycorp.com:8443/", "sidecar.mycorp.com:8443"},
{"HTTPS://sidecar.mycorp.com", "sidecar.mycorp.com"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
Expand Down
Loading
Loading