fix(downstream): 回调客户端只连接公网地址且不跟随重定向

This commit is contained in:
dujiao-next committed 2026-09-15 22:17:40 +08:00
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
}