fix(downstream): 回调客户端只连接公网地址且不跟随重定向
This commit is contained in:
1 parent
9f8579ca93
commit
8d73eb723b
3 files changed
+107
-3
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user