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) } }