diff --git a/internal/modules/downstreamcallback/infrastructure/callbackclient/client.go b/internal/modules/downstreamcallback/infrastructure/callbackclient/client.go index 0d85ac6d..506ff476 100644 --- a/internal/modules/downstreamcallback/infrastructure/callbackclient/client.go +++ b/internal/modules/downstreamcallback/infrastructure/callbackclient/client.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "strings" - "time" downstreamcontract "github.com/dujiao-next/internal/modules/downstreamcallback/contract" "github.com/dujiao-next/internal/upstream" @@ -24,7 +23,7 @@ type Client struct { var _ downstreamcontract.Deliverer = (*Client)(nil) func New() *Client { - return NewWithHTTPClient(&http.Client{Timeout: 15 * time.Second}) + return NewWithHTTPClient(newSafeHTTPClient()) } func NewWithHTTPClient(client *http.Client) *Client { diff --git a/internal/modules/downstreamcallback/infrastructure/callbackclient/client_test.go b/internal/modules/downstreamcallback/infrastructure/callbackclient/client_test.go index c3067bf4..6fcb05f9 100644 --- a/internal/modules/downstreamcallback/infrastructure/callbackclient/client_test.go +++ b/internal/modules/downstreamcallback/infrastructure/callbackclient/client_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "net" "net/http" "net/http/httptest" "strconv" @@ -42,7 +43,7 @@ func TestClientSendsSignedCallback(t *testing.T) { })) t.Cleanup(server.Close) - client := New() + client := NewWithHTTPClient(server.Client()) err := client.Send(context.Background(), downstreamcontract.DeliveryRequest{ URL: server.URL, APIKey: "downstream-key", @@ -73,3 +74,35 @@ func TestClientRejectsNonSuccessContract(t *testing.T) { t.Fatal("Send() should reject non-success response") } } + +func TestDefaultClientRejectsLoopbackTarget(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + t.Errorf("loopback callback target must not be reached") + _, _ = writer.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(server.Close) + + err := New().Send(context.Background(), downstreamcontract.DeliveryRequest{ + URL: server.URL, + APIKey: "downstream-key", + Payload: downstreamcontract.CallbackPayload{Event: "order.fulfilled", Timestamp: 1_700_000_000}, + }) + if err == nil { + t.Fatalf("Send() to loopback target should fail") + } +} + +func TestIsPublicIP(t *testing.T) { + for _, tc := range []struct { + ip string + want bool + }{ + {"127.0.0.1", false}, {"10.1.2.3", false}, {"192.168.1.1", false}, {"172.16.0.9", false}, + {"169.254.169.254", false}, {"100.64.0.1", false}, {"0.0.0.0", false}, {"::1", false}, {"fd00::1", false}, + {"8.8.8.8", true}, {"1.1.1.1", true}, {"2606:4700::1111", true}, + } { + if got := isPublicIP(net.ParseIP(tc.ip)); got != tc.want { + t.Errorf("isPublicIP(%s) = %v, want %v", tc.ip, got, tc.want) + } + } +} diff --git a/internal/modules/downstreamcallback/infrastructure/callbackclient/transport.go b/internal/modules/downstreamcallback/infrastructure/callbackclient/transport.go new file mode 100644 index 00000000..00bf71e9 --- /dev/null +++ b/internal/modules/downstreamcallback/infrastructure/callbackclient/transport.go @@ -0,0 +1,72 @@ +package callbackclient + +import ( + "context" + "errors" + "net" + "net/http" + "time" +) + +// newSafeHTTPClient 返回用于向下游回调地址发起请求的 HTTP 客户端: +// 拨号时解析域名并只连接公网地址,不跟随重定向。 +func newSafeHTTPClient() *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: safeDialContext(dialer), + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + MaxIdleConns: 16, + IdleConnTimeout: 60 * time.Second, + } + return &http.Client{ + Timeout: 15 * time.Second, + Transport: transport, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +var errCallbackTargetForbidden = errors.New("callback target resolves to a forbidden address") + +func safeDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + var lastErr error = errCallbackTargetForbidden + for _, ip := range ips { + if !isPublicIP(ip.IP) { + continue + } + conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, lastErr + } +} + +func isPublicIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() { + return false + } + // 100.64.0.0/10(运营商级 NAT) + if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64 { + return false + } + return true +}