Files
NianAIGC/backend/internal/webhook/http_sender_test.go

198 lines
8.0 KiB
Go

package webhook
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
type lookupIPFunc func(context.Context, string) ([]net.IPAddr, error)
func (function lookupIPFunc) LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) {
return function(ctx, host)
}
type contextDialFunc func(context.Context, string, string) (net.Conn, error)
func (function contextDialFunc) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
return function(ctx, network, address)
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return function(request)
}
func TestHTTPSenderPreservesSignedRequestAndReturnsStatus(t *testing.T) {
client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(request.Body)
if request.Method != http.MethodPost || request.URL.String() != "https://hooks.example.test/job" || string(body) != `{"jobId":"job"}` || request.Header.Get("X-Zhinian-Signature") != "sha256=abc" {
t.Fatalf("request=%s %s body=%s headers=%v", request.Method, request.URL, body, request.Header)
}
return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("ignored")), Header: make(http.Header)}, nil
})}
sender, err := NewHTTPSender(client, func(context.Context, *url.URL) error { return nil })
if err != nil {
t.Fatal(err)
}
response, err := sender.Send(context.Background(), Request{URL: "https://hooks.example.test/job", Body: []byte(`{"jobId":"job"}`), Headers: map[string]string{"X-Zhinian-Signature": "sha256=abc"}})
if err != nil || response.Status != 204 {
t.Fatalf("Send=%#v,%v", response, err)
}
}
func TestHTTPSenderRejectsInvalidAndPolicyDeniedDestinations(t *testing.T) {
client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("transport must not run")
return nil, nil
})}
sender, _ := NewHTTPSender(client, func(context.Context, *url.URL) error { return errors.New("private address") })
for _, target := range []string{"file:///etc/passwd", "https://user:secret@example.test", "https://127.0.0.1/hook"} {
if _, err := sender.Send(context.Background(), Request{URL: target}); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("target=%q error=%v", target, err)
}
}
}
func TestHTTPSenderRevalidatesEveryRedirect(t *testing.T) {
validated := make([]string, 0, 2)
client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Hostname() != "public.test" {
t.Fatal("redirect target transport must not run")
}
return &http.Response{StatusCode: http.StatusTemporaryRedirect, Header: http.Header{"Location": []string{"https://private.test/hook"}}, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil
})}
sender, err := NewHTTPSender(client, func(_ context.Context, target *url.URL) error {
validated = append(validated, target.Hostname())
if target.Hostname() == "private.test" {
return errors.New("private destination")
}
return nil
})
if err != nil {
t.Fatal(err)
}
if _, err := sender.Send(context.Background(), Request{URL: "https://public.test/hook"}); err == nil {
t.Fatal("policy-denied redirect was accepted")
}
if strings.Join(validated, ",") != "public.test,private.test" {
t.Fatalf("validated destinations = %v", validated)
}
}
func TestNewHTTPSenderRequiresBoundedClientAndPolicy(t *testing.T) {
if _, err := NewHTTPSender(nil, func(context.Context, *url.URL) error { return nil }); err == nil {
t.Fatal("nil client accepted")
}
if _, err := NewHTTPSender(&http.Client{}, func(context.Context, *url.URL) error { return nil }); err == nil {
t.Fatal("unbounded client accepted")
}
if _, err := NewHTTPSender(&http.Client{Timeout: time.Second}, nil); err == nil {
t.Fatal("nil policy accepted")
}
}
func TestPublicDestinationPolicyRejectsNonPublicDestinations(t *testing.T) {
tests := []struct {
name string
url string
ips []net.IPAddr
}{
{name: "userinfo", url: "https://user:secret@example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}},
{name: "localhost name", url: "https://localhost/hook", ips: []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}},
{name: "loopback", url: "https://example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}},
{name: "private", url: "https://example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}},
{name: "link local", url: "https://example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("169.254.169.254")}}},
{name: "multicast", url: "https://example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("224.0.0.1")}}},
{name: "unspecified", url: "https://example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("0.0.0.0")}}},
{name: "mixed answers", url: "https://example.test/hook", ips: []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("192.168.1.1")}}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
policy := NewPublicDestinationPolicy(lookupIPFunc(func(context.Context, string) ([]net.IPAddr, error) { return test.ips, nil }), nil)
u, _ := url.Parse(test.url)
if err := policy.Validate(context.Background(), u); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("Validate(%q) = %v", test.url, err)
}
})
}
}
func TestPublicDestinationPolicyRejectsSpecialUseUnicastAddresses(t *testing.T) {
addresses := []string{
"100.64.0.1", // shared address space (CGNAT)
"192.0.0.1", // IETF protocol assignments
"192.0.2.1", // documentation
"198.18.0.1", // benchmarking
"198.51.100.1", // documentation
"203.0.113.1", // documentation
"240.0.0.1", // reserved
"64:ff9b::a00:1", // IPv4/IPv6 translation can embed private IPv4
"100::1", // discard-only
"2001::1", // Teredo can embed non-public IPv4
"2001:2::1", // benchmarking
"2001:db8::1", // documentation
"2001:10::1", // deprecated ORCHID
"2001:20::1", // ORCHIDv2
}
for _, address := range addresses {
t.Run(address, func(t *testing.T) {
policy := NewPublicDestinationPolicy(nil, nil)
target, err := url.Parse("https://[" + address + "]/hook")
if net.ParseIP(address).To4() != nil {
target, err = url.Parse("https://" + address + "/hook")
}
if err != nil {
t.Fatal(err)
}
if err := policy.Validate(context.Background(), target); err == nil {
t.Fatalf("special-use address %s was accepted", address)
}
})
}
}
func TestPublicDestinationPolicyFailsClosedOnDNSFailure(t *testing.T) {
policy := NewPublicDestinationPolicy(lookupIPFunc(func(context.Context, string) ([]net.IPAddr, error) {
return nil, errors.New("resolver unavailable")
}), nil)
u, _ := url.Parse("https://hooks.example.test/job")
if err := policy.Validate(context.Background(), u); err == nil {
t.Fatal("DNS failure was accepted")
}
}
func TestPublicDestinationPolicyAllowsPublicAddress(t *testing.T) {
policy := NewPublicDestinationPolicy(lookupIPFunc(func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
}), nil)
u, _ := url.Parse("https://hooks.example.test/job")
if err := policy.Validate(context.Background(), u); err != nil {
t.Fatalf("public destination rejected: %v", err)
}
}
func TestPublicDestinationPolicyDialsValidatedIPAddress(t *testing.T) {
var dialed string
policy := NewPublicDestinationPolicy(
lookupIPFunc(func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
}),
contextDialFunc(func(_ context.Context, _, address string) (net.Conn, error) {
dialed = address
return nil, errors.New("dial stopped by test")
}),
)
_, _ = policy.DialContext(context.Background(), "tcp", "hooks.example.test:443")
if dialed != "93.184.216.34:443" {
t.Fatalf("dialed address = %q", dialed)
}
}