mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
388 lines
13 KiB
Go
388 lines
13 KiB
Go
// 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/",
|
|
// 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 {
|
|
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",
|
|
"https://x@evil.com",
|
|
"https://user:pass@sidecar.mycorp.com",
|
|
} {
|
|
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_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", // 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 rejection (http remote), got nil", addr)
|
|
continue
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
// 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) {
|
|
if got := ProxyHost(tt.input); got != tt.want {
|
|
t.Errorf("ProxyHost(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|