diff --git a/pkg/channels/weixin/media.go b/pkg/channels/weixin/media.go index cf1b456126..f6866d9c22 100644 --- a/pkg/channels/weixin/media.go +++ b/pkg/channels/weixin/media.go @@ -28,8 +28,17 @@ import ( basechannels "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) +func (c *WeixinChannel) mediaHTTP() *http.Client { + if c.mediaClient != nil { + return c.mediaClient + } + // Tests construct channels with a stub api.HttpClient only. + return c.api.HttpClient +} + const ( weixinMediaMaxBytes = 100 << 20 weixinTypingKeepAlive = 5 * time.Second @@ -194,11 +203,14 @@ func uniqCDNURLs(urls []string) []string { } func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) { + if err := utils.ValidateSafeHTTPURL(downloadURL, nil, nil); err != nil { + return nil, 0, fmt.Errorf("cdn download: %w", err) + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) if err != nil { return nil, 0, err } - resp, err := c.api.HttpClient.Do(req) + resp, err := c.mediaHTTP().Do(req) if err != nil { return nil, 0, err } @@ -656,11 +668,14 @@ func (c *WeixinChannel) downloadRemoteMediaToTemp( rawURL, fallbackName string, ) (string, string, string, error) { + if err := utils.ValidateSafeHTTPURL(rawURL, nil, nil); err != nil { + return "", "", "", fmt.Errorf("remote media: %w", err) + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return "", "", "", err } - resp, err := c.api.HttpClient.Do(req) + resp, err := c.mediaHTTP().Do(req) if err != nil { return "", "", "", err } @@ -851,6 +866,9 @@ func (c *WeixinChannel) uploadBufferToCDN( } uploadURL = buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey) } + if err := utils.ValidateSafeHTTPURL(uploadURL, nil, nil); err != nil { + return "", fmt.Errorf("cdn upload: %w", err) + } var lastErr error for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ { @@ -860,7 +878,7 @@ func (c *WeixinChannel) uploadBufferToCDN( } req.Header.Set("Content-Type", "application/octet-stream") - resp, doErr := c.api.HttpClient.Do(req) + resp, doErr := c.mediaHTTP().Do(req) if doErr != nil { lastErr = doErr } else { diff --git a/pkg/channels/weixin/media_test.go b/pkg/channels/weixin/media_test.go new file mode 100644 index 0000000000..e225a7b6ab --- /dev/null +++ b/pkg/channels/weixin/media_test.go @@ -0,0 +1,52 @@ +package weixin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/utils" +) + +func TestDownloadRemoteMediaToTemp_BlocksPrivateRedirect(t *testing.T) { + t.Parallel() + + privateHit := false + private := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + privateHit = true + _, _ = w.Write([]byte("SECRET")) + })) + t.Cleanup(private.Close) + + public := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, private.URL+"/secret", http.StatusFound) + })) + t.Cleanup(public.Close) + + client, err := utils.CreateSafeHTTPClient(utils.SafeHTTPClientOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("CreateSafeHTTPClient: %v", err) + } + + ch := &WeixinChannel{ + mediaClient: client, + api: &ApiClient{HttpClient: &http.Client{}}, + } + + _, _, _, err = ch.downloadRemoteMediaToTemp(context.Background(), public.URL, "file.bin") + if err == nil { + t.Fatal("expected downloadRemoteMediaToTemp to reject redirect to private host") + } + if privateHit { + t.Fatal("private target was reached via redirect") + } + if !strings.Contains(err.Error(), "private or local") && + !strings.Contains(err.Error(), "blocked private") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/channels/weixin/weixin.go b/pkg/channels/weixin/weixin.go index 2897d2422b..31c043a045 100644 --- a/pkg/channels/weixin/weixin.go +++ b/pkg/channels/weixin/weixin.go @@ -3,6 +3,7 @@ package weixin import ( "context" "fmt" + "net/http" "strings" "sync" "time" @@ -14,16 +15,20 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) +const weixinMediaHTTPTimeout = 30 * time.Second + // WeixinChannel is the Weixin channel implementation over Tencent iLink REST API. type WeixinChannel struct { *channels.BaseChannel - api *ApiClient - config *config.WeixinSettings - ctx context.Context - cancel context.CancelFunc - bus *bus.MessageBus + api *ApiClient + mediaClient *http.Client + config *config.WeixinSettings + ctx context.Context + cancel context.CancelFunc + bus *bus.MessageBus // contextTokens stores the last context_token per user (from_user_id → context_token). // This is required by the iLink API to associate replies with the right chat session. contextTokens sync.Map @@ -71,6 +76,14 @@ func NewWeixinChannel( return nil, fmt.Errorf("weixin: failed to create API client: %w", err) } + mediaClient, err := utils.CreateSafeHTTPClient(utils.SafeHTTPClientOptions{ + ProxyURL: cfg.Proxy, + Timeout: weixinMediaHTTPTimeout, + }) + if err != nil { + return nil, fmt.Errorf("weixin: failed to create media http client: %w", err) + } + base := channels.NewBaseChannel( bc.Name(), cfg, @@ -83,6 +96,7 @@ func NewWeixinChannel( return &WeixinChannel{ BaseChannel: base, api: api, + mediaClient: mediaClient, config: cfg, bus: messageBus, typingCache: make(map[string]typingTicketCacheEntry),