Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions pkg/channels/weixin/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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++ {
Expand All @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions pkg/channels/weixin/media_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 19 additions & 5 deletions pkg/channels/weixin/weixin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package weixin
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -83,6 +96,7 @@ func NewWeixinChannel(
return &WeixinChannel{
BaseChannel: base,
api: api,
mediaClient: mediaClient,
config: cfg,
bus: messageBus,
typingCache: make(map[string]typingTicketCacheEntry),
Expand Down