feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -0,0 +1,139 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IPResolver is the DNS seam used by PublicDestinationPolicy.
|
||||
type IPResolver interface {
|
||||
LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
|
||||
}
|
||||
|
||||
// ContextDialer is the network seam used after DNS answers have been checked.
|
||||
type ContextDialer interface {
|
||||
DialContext(context.Context, string, string) (net.Conn, error)
|
||||
}
|
||||
|
||||
// DestinationPolicy validates an outbound HTTP destination.
|
||||
type DestinationPolicy interface {
|
||||
Validate(context.Context, *url.URL) error
|
||||
}
|
||||
|
||||
// PublicDestinationValidator allows only HTTP(S) destinations whose complete DNS
|
||||
// answer set contains public unicast addresses. DialContext resolves and dials
|
||||
// one of those checked addresses directly, avoiding a second system DNS lookup.
|
||||
type PublicDestinationValidator struct {
|
||||
resolver IPResolver
|
||||
dialer ContextDialer
|
||||
}
|
||||
|
||||
func NewPublicDestinationPolicy(resolver IPResolver, dialer ContextDialer) *PublicDestinationValidator {
|
||||
if resolver == nil {
|
||||
resolver = net.DefaultResolver
|
||||
}
|
||||
if dialer == nil {
|
||||
dialer = &net.Dialer{}
|
||||
}
|
||||
return &PublicDestinationValidator{resolver: resolver, dialer: dialer}
|
||||
}
|
||||
|
||||
// PublicDestinationPolicy is the compatibility validator factory used by
|
||||
// callers that provide their own bounded http.Client.
|
||||
func PublicDestinationPolicy(resolver IPResolver) func(context.Context, *url.URL) error {
|
||||
return NewPublicDestinationPolicy(resolver, nil).Validate
|
||||
}
|
||||
|
||||
func (policy *PublicDestinationValidator) Validate(ctx context.Context, target *url.URL) error {
|
||||
_, err := policy.resolve(ctx, target)
|
||||
return err
|
||||
}
|
||||
|
||||
func (policy *PublicDestinationValidator) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid outbound address")
|
||||
}
|
||||
target := &url.URL{Scheme: "http", Host: net.JoinHostPort(host, port)}
|
||||
ips, err := policy.resolve(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policy.dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
|
||||
}
|
||||
|
||||
func (policy *PublicDestinationValidator) resolve(ctx context.Context, target *url.URL) ([]net.IP, error) {
|
||||
if target == nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil {
|
||||
return nil, errors.New("outbound destination is invalid")
|
||||
}
|
||||
host := strings.TrimSuffix(strings.ToLower(target.Hostname()), ".")
|
||||
if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
||||
return nil, errors.New("outbound destination is not public")
|
||||
}
|
||||
var ips []net.IP
|
||||
if literal := net.ParseIP(host); literal != nil {
|
||||
ips = []net.IP{literal}
|
||||
} else {
|
||||
answers, err := policy.resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(answers) == 0 {
|
||||
return nil, errors.New("outbound destination DNS lookup failed")
|
||||
}
|
||||
for _, answer := range answers {
|
||||
ips = append(ips, answer.IP)
|
||||
}
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicUnicastIP(ip) {
|
||||
return nil, errors.New("outbound destination is not public")
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func isPublicUnicastIP(ip net.IP) bool {
|
||||
if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
address, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
address = address.Unmap()
|
||||
for _, prefix := range nonPublicSpecialUsePrefixes {
|
||||
if prefix.Contains(address) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// IsGlobalUnicast deliberately includes several IANA special-purpose ranges.
|
||||
// These networks are not valid public callback destinations even though the Go
|
||||
// standard library does not classify all of them as private or link-local.
|
||||
var nonPublicSpecialUsePrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/8"),
|
||||
netip.MustParsePrefix("100.64.0.0/10"),
|
||||
netip.MustParsePrefix("192.0.0.0/29"),
|
||||
netip.MustParsePrefix("192.0.0.170/31"),
|
||||
netip.MustParsePrefix("192.0.2.0/24"),
|
||||
netip.MustParsePrefix("192.88.99.0/24"),
|
||||
netip.MustParsePrefix("198.18.0.0/15"),
|
||||
netip.MustParsePrefix("198.51.100.0/24"),
|
||||
netip.MustParsePrefix("203.0.113.0/24"),
|
||||
netip.MustParsePrefix("240.0.0.0/4"),
|
||||
netip.MustParsePrefix("64:ff9b::/96"),
|
||||
netip.MustParsePrefix("64:ff9b:1::/48"),
|
||||
netip.MustParsePrefix("100::/64"),
|
||||
netip.MustParsePrefix("2001::/32"),
|
||||
netip.MustParsePrefix("2001:2::/48"),
|
||||
netip.MustParsePrefix("2001:10::/28"),
|
||||
netip.MustParsePrefix("2001:20::/28"),
|
||||
netip.MustParsePrefix("2001:db8::/32"),
|
||||
netip.MustParsePrefix("2002::/16"),
|
||||
netip.MustParsePrefix("3fff::/20"),
|
||||
netip.MustParsePrefix("5f00::/16"),
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package webhook owns the generation callback wire and transport policy.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewPublicHTTPSender constructs the production sender with a transport that
|
||||
// resolves, validates, and then directly dials the same public IP answer.
|
||||
func NewPublicHTTPSender(timeout time.Duration, policy *PublicDestinationValidator) (*HTTPSender, error) {
|
||||
if policy == nil || timeout <= 0 {
|
||||
return nil, errors.New("webhook HTTP sender requires a bounded client and destination policy")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = policy.DialContext
|
||||
return NewHTTPSender(&http.Client{Timeout: timeout, Transport: transport}, policy.Validate)
|
||||
}
|
||||
|
||||
// HTTPSender is a bounded callback transport. Destination policy remains
|
||||
// injectable at composition time so deployments can enforce DNS/IP controls
|
||||
// without coupling the byte/HMAC contract to one network environment.
|
||||
type HTTPSender struct {
|
||||
client *http.Client
|
||||
validate func(context.Context, *url.URL) error
|
||||
}
|
||||
|
||||
func NewHTTPSender(client *http.Client, validate func(context.Context, *url.URL) error) (*HTTPSender, error) {
|
||||
if client == nil || client.Timeout <= 0 || validate == nil {
|
||||
return nil, errors.New("webhook HTTP sender requires a bounded client and destination policy")
|
||||
}
|
||||
copy := *client
|
||||
previous := copy.CheckRedirect
|
||||
copy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("webhook redirect limit exceeded")
|
||||
}
|
||||
if err := validate(request.Context(), request.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if previous != nil {
|
||||
return previous(request, via)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &HTTPSender{client: ©, validate: validate}, nil
|
||||
}
|
||||
|
||||
func (sender *HTTPSender) Send(ctx context.Context, input Request) (Response, error) {
|
||||
target, err := url.Parse(input.URL)
|
||||
if err != nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil {
|
||||
return Response{}, errors.New("webhook destination is invalid")
|
||||
}
|
||||
if err := sender.validate(ctx, target); err != nil {
|
||||
return Response{}, errors.New("webhook destination is not allowed")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(input.Body))
|
||||
if err != nil {
|
||||
return Response{}, errors.New("build webhook request")
|
||||
}
|
||||
for key, value := range input.Headers {
|
||||
request.Header.Set(key, value)
|
||||
}
|
||||
response, err := sender.client.Do(request)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return Response{}, ctx.Err()
|
||||
}
|
||||
return Response{}, errors.New("send webhook request")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10))
|
||||
return Response{Status: response.StatusCode}, nil
|
||||
}
|
||||
|
||||
var _ Sender = (*HTTPSender)(nil)
|
||||
@@ -0,0 +1,197 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user