From 30f349d06addd52454e4db1f1e9162fe0a3f43f4 Mon Sep 17 00:00:00 2001 From: nullkey Date: Mon, 13 Apr 2026 20:43:17 +0800 Subject: [PATCH] feat(im): support custom endpoints for private WeCom deployments Allow configuring custom WebSocket and API base URLs for WeCom via optional credentials fields (ws_endpoint, api_base_url), enabling integration with privately deployed WeCom instances. Changes: - LongConnClient and WebhookAdapter accept custom endpoint URLs - Endpoint URLs validated at construction (scheme + SSRF check) - Private deployment hostnames added to file download SSRF allowlist - Frontend forms added for both WebSocket and Webhook modes - i18n support for zh-CN, en-US, ru-RU, ko-KR Uses default public cloud endpoints when not specified. --- frontend/src/components/IMChannelPanel.vue | 10 ++ frontend/src/i18n/locales/en-US.ts | 2 + frontend/src/i18n/locales/ko-KR.ts | 2 + frontend/src/i18n/locales/ru-RU.ts | 2 + frontend/src/i18n/locales/zh-CN.ts | 2 + internal/container/container.go | 7 +- internal/im/wecom/longconn.go | 32 +++-- internal/im/wecom/mention_test.go | 5 +- internal/im/wecom/webhook_adapter.go | 133 ++++++++++++++------- internal/im/wecom/ws_adapter.go | 2 +- 10 files changed, 144 insertions(+), 53 deletions(-) diff --git a/frontend/src/components/IMChannelPanel.vue b/frontend/src/components/IMChannelPanel.vue index 24668ab6b..28f25f38f 100644 --- a/frontend/src/components/IMChannelPanel.vue +++ b/frontend/src/components/IMChannelPanel.vue @@ -174,6 +174,11 @@ +
+ + +

{{ $t('agentEditor.im.wecomWSEndpointHint') }}

+
diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index cf7ff963c..a245f1d2c 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -3349,6 +3349,8 @@ export default { 'When on, bot replies are new top-level posts in the channel. When off (default), they stay in the thread and the main view only shows “N replies”.', modeHint: 'WebSocket is recommended for easier setup', consoleTip: 'to get credentials', + wecomWSEndpointHint: 'Optional. For private WeCom deployments, enter a custom WebSocket address. Leave empty to use the default public cloud endpoint. For internal network addresses, add the hostname to the SSRF_WHITELIST env var.', + wecomAPIBaseURLHint: 'Optional. For private WeCom deployments, enter a custom API base URL. Leave empty to use the default public cloud endpoint. For internal network addresses, add the hostname to the SSRF_WHITELIST env var.', fileKnowledgeBase: 'File Storage Knowledge Base', fileKnowledgeBasePlaceholder: 'Select a knowledge base (optional)', fileKnowledgeBaseHint: 'When configured, files sent by users will be automatically saved to this knowledge base', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 57200733b..1c584aca8 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -3376,6 +3376,8 @@ export default { "켜면 봇 답변이 채널의 새 최상위 게시물로 올라갑니다. 끄면(기본) 스레드 답변이며 메인 화면에는 「N개 답변」만 보입니다.", modeHint: "WebSocket 방식이 설정이 더 간편하여 권장됩니다", consoleTip: "자격 증명 정보를 가져오세요", + wecomWSEndpointHint: "선택 사항. 프라이빗 WeCom 배포 시 사용자 정의 WebSocket 주소를 입력하세요. 비워두면 기본 퍼블릭 클라우드 주소를 사용합니다. 내부 네트워크 주소의 경우 SSRF_WHITELIST 환경 변수에 호스트명을 추가하세요.", + wecomAPIBaseURLHint: "선택 사항. 프라이빗 WeCom 배포 시 사용자 정의 API 기본 URL을 입력하세요. 비워두면 기본 퍼블릭 클라우드 주소를 사용합니다. 내부 네트워크 주소의 경우 SSRF_WHITELIST 환경 변수에 호스트명을 추가하세요.", fileKnowledgeBase: "파일 저장 지식 베이스", fileKnowledgeBasePlaceholder: "지식 베이스 선택 (선택 사항)", fileKnowledgeBaseHint: "설정 시 사용자가 보낸 파일이 자동으로 해당 지식 베이스에 저장됩니다", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index ab81a6c3e..94dee79fd 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -3014,6 +3014,8 @@ export default { 'Включено — ответы бота как новые сообщения в канале. Выключено (по умолчанию) — ответы в ветке, в основном окне только «N ответов».', modeHint: 'Рекомендуется WebSocket — проще настроить', consoleTip: 'для получения учётных данных', + wecomWSEndpointHint: 'Необязательно. Для приватного развёртывания WeCom укажите пользовательский адрес WebSocket. Оставьте пустым для публичного облака. Для адресов внутренней сети добавьте имя хоста в переменную SSRF_WHITELIST.', + wecomAPIBaseURLHint: 'Необязательно. Для приватного развёртывания WeCom укажите пользовательский базовый URL API. Оставьте пустым для публичного облака. Для адресов внутренней сети добавьте имя хоста в переменную SSRF_WHITELIST.', fileKnowledgeBase: 'База знаний для файлов', fileKnowledgeBasePlaceholder: 'Выберите базу знаний (необязательно)', fileKnowledgeBaseHint: 'При настройке файлы, отправленные пользователями, автоматически сохраняются в эту базу знаний', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 75902aecc..6bf46465f 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -3324,6 +3324,8 @@ export default { "开启后 Bot 回复作为频道内新帖子;关闭(默认)则作为原消息的线程回复,主窗口仅显示「N 条回复」。", modeHint: "推荐使用 WebSocket 方式接入,配置更简单", consoleTip: "前往获取凭证信息", + wecomWSEndpointHint: "可选,私有化部署时填写自定义 WebSocket 地址,留空则使用默认公有云地址。内网地址需将域名加入 SSRF_WHITELIST 环境变量", + wecomAPIBaseURLHint: "可选,私有化部署时填写自定义 API 地址,留空则使用默认公有云地址。内网地址需将域名加入 SSRF_WHITELIST 环境变量", fileKnowledgeBase: "文件保存知识库", fileKnowledgeBasePlaceholder: "选择知识库(可选)", fileKnowledgeBaseHint: "配置后,用户发送的文件将自动保存到该知识库中", diff --git a/internal/container/container.go b/internal/container/container.go index 858800592..bc56bcce2 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -1059,6 +1059,7 @@ func registerIMAdapterFactories(imService *imPkg.Service) { getString(creds, "token"), getString(creds, "encoding_aes_key"), corpAgentID, + getString(creds, "api_base_url"), ) if err != nil { return nil, nil, err @@ -1066,12 +1067,16 @@ func registerIMAdapterFactories(imService *imPkg.Service) { return adapter, nil, nil case "websocket": - client := wecom.NewLongConnClient( + client, err := wecom.NewLongConnClient( getString(creds, "bot_id"), getString(creds, "bot_secret"), + getString(creds, "ws_endpoint"), getString(creds, "bot_name"), msgHandler, ) + if err != nil { + return nil, nil, err + } wsCtx, wsCancel := context.WithCancel(context.Background()) go func() { diff --git a/internal/im/wecom/longconn.go b/internal/im/wecom/longconn.go index e9ead3134..fe202ae5f 100644 --- a/internal/im/wecom/longconn.go +++ b/internal/im/wecom/longconn.go @@ -27,7 +27,7 @@ import ( ) const ( - wecomWSEndpoint = "wss://openws.work.weixin.qq.com" + defaultWSEndpoint = "wss://openws.work.weixin.qq.com" cmdSubscribe = "aibot_subscribe" cmdPing = "ping" @@ -122,9 +122,11 @@ type MessageHandler func(ctx context.Context, msg *im.IncomingMessage) error // LongConnClient manages a WeCom intelligent bot WebSocket long connection. type LongConnClient struct { - botID string - secret string - handler MessageHandler + botID string + secret string + endpoint string + extraAllowedHost string // hostname from custom endpoint for SSRF allowlist + handler MessageHandler conn *ws.Conn mu sync.Mutex @@ -143,17 +145,27 @@ type LongConnClient struct { } // NewLongConnClient creates a WeCom long connection client. +// wsEndpoint overrides the default WebSocket URL; empty uses the public cloud endpoint. // botName is the bot's display name for @mention stripping; empty to auto-detect. -func NewLongConnClient(botID, secret, botName string, handler MessageHandler) *LongConnClient { +func NewLongConnClient(botID, secret, wsEndpoint, botName string, handler MessageHandler) (*LongConnClient, error) { + if wsEndpoint == "" { + wsEndpoint = defaultWSEndpoint + } + wsEndpoint = strings.TrimRight(wsEndpoint, "/") + if err := validateEndpointURL(wsEndpoint, defaultWSEndpoint, "wss"); err != nil { + return nil, fmt.Errorf("invalid ws_endpoint: %w", err) + } c := &LongConnClient{ - botID: botID, - secret: secret, - handler: handler, + botID: botID, + secret: secret, + endpoint: wsEndpoint, + extraAllowedHost: extraHostFromEndpoint(wsEndpoint, defaultWSEndpoint), + handler: handler, } if botName != "" { c.botDisplayName.Store(botName) } - return c + return c, nil } // Start connects and runs the long connection loop. It reconnects automatically on failure. @@ -345,7 +357,7 @@ func (c *LongConnClient) sendStreamFrame(incoming *im.IncomingMessage, streamID, } func (c *LongConnClient) connectAndRun(ctx context.Context) error { - conn, _, err := ws.DefaultDialer.DialContext(ctx, wecomWSEndpoint, nil) + conn, _, err := ws.DefaultDialer.DialContext(ctx, c.endpoint, nil) if err != nil { return fmt.Errorf("dial: %w", err) } diff --git a/internal/im/wecom/mention_test.go b/internal/im/wecom/mention_test.go index 51887fc5e..62767fd85 100644 --- a/internal/im/wecom/mention_test.go +++ b/internal/im/wecom/mention_test.go @@ -85,7 +85,10 @@ func TestLongConnClient_StripAtMention(t *testing.T) { }) t.Run("NewLongConnClient with bot name", func(t *testing.T) { - c := NewLongConnClient("id", "secret", "My Bot", nil) + c, err := NewLongConnClient("id", "secret", "", "My Bot", nil) + if err != nil { + t.Fatal(err) + } got := c.stripAtMention("@My Bot /help") if got != "/help" { diff --git a/internal/im/wecom/webhook_adapter.go b/internal/im/wecom/webhook_adapter.go index a46bb20a7..1166eb850 100644 --- a/internal/im/wecom/webhook_adapter.go +++ b/internal/im/wecom/webhook_adapter.go @@ -39,15 +39,52 @@ import ( var httpClient = &http.Client{Timeout: 30 * time.Second} +const defaultAPIBaseURL = "https://qyapi.weixin.qq.com" + +// extraHostFromEndpoint returns the lowercased hostname from endpoint if it +// differs from defaultEndpoint; otherwise returns "". Used to extend the SSRF +// allowlist for private deployments. +func extraHostFromEndpoint(endpoint, defaultEndpoint string) string { + if endpoint == "" || endpoint == defaultEndpoint { + return "" + } + if u, err := url.Parse(endpoint); err == nil { + return strings.ToLower(u.Hostname()) + } + return "" +} + +// validateEndpointURL checks that a custom endpoint URL uses a secure scheme +// and does not point to a private/internal address, preventing accidental +// credential leakage (e.g. access tokens sent to a rogue server). +func validateEndpointURL(endpoint, defaultEndpoint, requiredScheme string) error { + if endpoint == "" || endpoint == defaultEndpoint { + return nil + } + u, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("invalid endpoint URL: %w", err) + } + if u.Scheme != requiredScheme { + return fmt.Errorf("endpoint must use %s:// scheme, got %s://", requiredScheme, u.Scheme) + } + if err := secutils.ValidateURLForSSRF(endpoint); err != nil { + return fmt.Errorf("%w (for private deployments on internal networks, add the hostname to SSRF_WHITELIST)", err) + } + return nil +} + // WebhookAdapter implements im.Adapter for WeCom in webhook (self-built app callback) mode. // Messages arrive via HTTP callback; replies are sent via the WeCom REST API. type WebhookAdapter struct { - corpID string - token string - encodingAESKey string - aesKey []byte - agentSecret string - corpAgentID int + corpID string + token string + encodingAESKey string + aesKey []byte + agentSecret string + corpAgentID int + apiBaseURL string // WeCom API base URL (e.g. "https://qyapi.weixin.qq.com") + extraAllowedHost string // hostname from apiBaseURL for SSRF allowlist (empty if default) // Token cache tokenMu sync.Mutex @@ -59,20 +96,32 @@ type WebhookAdapter struct { var _ im.FileDownloader = (*WebhookAdapter)(nil) // NewWebhookAdapter creates a new WeCom webhook adapter. -func NewWebhookAdapter(corpID, agentSecret, token, encodingAESKey string, corpAgentID int) (*WebhookAdapter, error) { +// apiBaseURL overrides the default WeCom API base URL; empty uses the public cloud endpoint. +func NewWebhookAdapter(corpID, agentSecret, token, encodingAESKey string, corpAgentID int, apiBaseURL string) (*WebhookAdapter, error) { // Decode the AES key from base64 aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") if err != nil { return nil, fmt.Errorf("decode encoding_aes_key: %w", err) } + if apiBaseURL == "" { + apiBaseURL = defaultAPIBaseURL + } + apiBaseURL = strings.TrimRight(apiBaseURL, "/") + + if err := validateEndpointURL(apiBaseURL, defaultAPIBaseURL, "https"); err != nil { + return nil, fmt.Errorf("invalid api_base_url: %w", err) + } + return &WebhookAdapter{ - corpID: corpID, - token: token, - encodingAESKey: encodingAESKey, - aesKey: aesKey, - agentSecret: agentSecret, - corpAgentID: corpAgentID, + corpID: corpID, + token: token, + encodingAESKey: encodingAESKey, + aesKey: aesKey, + agentSecret: agentSecret, + corpAgentID: corpAgentID, + apiBaseURL: apiBaseURL, + extraAllowedHost: extraHostFromEndpoint(apiBaseURL, defaultAPIBaseURL), }, nil } @@ -257,7 +306,7 @@ func (a *WebhookAdapter) sendToAppChat(ctx context.Context, accessToken, chatID return fmt.Errorf("marshal payload: %w", err) } - sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=%s", accessToken) + sendURL := fmt.Sprintf("%s/cgi-bin/appchat/send?access_token=%s", a.apiBaseURL, accessToken) req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes)) if err != nil { return fmt.Errorf("create request: %w", err) @@ -301,7 +350,7 @@ func (a *WebhookAdapter) sendToUser(ctx context.Context, accessToken, userID str return fmt.Errorf("marshal payload: %w", err) } - sendURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", accessToken) + sendURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", a.apiBaseURL, accessToken) req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, bytes.NewReader(payloadBytes)) if err != nil { return fmt.Errorf("create request: %w", err) @@ -338,8 +387,8 @@ func (a *WebhookAdapter) getAccessToken(ctx context.Context) (string, error) { return a.tokenCache, nil } - tokenURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s", - a.corpID, a.agentSecret) + tokenURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", + a.apiBaseURL, a.corpID, a.agentSecret) req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) if err != nil { @@ -460,10 +509,10 @@ type wecomMessage struct { CreateTime int64 `xml:"CreateTime"` MsgType string `xml:"MsgType"` Content string `xml:"Content"` // text - PicUrl string `xml:"PicUrl"` // image: download URL - MediaId string `xml:"MediaId"` // image/voice/video: media ID for download - Format string `xml:"Format"` // voice: audio format (amr/speex) - ThumbMediaId string `xml:"ThumbMediaId"` // video: thumbnail media ID + PicUrl string `xml:"PicUrl"` // image: download URL + MediaId string `xml:"MediaId"` // image/voice/video: media ID for download + Format string `xml:"Format"` // voice: audio format (amr/speex) + ThumbMediaId string `xml:"ThumbMediaId"` // video: thumbnail media ID MsgID string `xml:"MsgId"` AgentID string `xml:"AgentID"` ChatID string `xml:"ChatId"` @@ -488,7 +537,7 @@ func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessa // If FileKey looks like a URL, download directly if strings.HasPrefix(msg.FileKey, "http://") || strings.HasPrefix(msg.FileKey, "https://") { - return downloadFromURL(ctx, msg.FileKey, fileName) + return downloadFromURL(ctx, msg.FileKey, fileName, a.extraAllowedHost) } // Otherwise treat as media_id, download via temporary media API @@ -497,9 +546,9 @@ func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessa return nil, "", fmt.Errorf("get access token: %w", err) } - apiURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s", - accessToken, msg.FileKey) - return downloadFromURL(ctx, apiURL, fileName) + apiURL := fmt.Sprintf("%s/cgi-bin/media/get?access_token=%s&media_id=%s", + a.apiBaseURL, accessToken, msg.FileKey) + return downloadFromURL(ctx, apiURL, fileName, a.extraAllowedHost) } // downloadFromURL performs a GET request and returns the response body. @@ -507,9 +556,9 @@ func (a *WebhookAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessa // 1. Content-Disposition: attachment; filename="xxx.pdf" // 2. Content-Type → extension mapping (fallback for platforms like WeCom that // don't provide the original filename in the callback JSON) -func downloadFromURL(ctx context.Context, rawURL, fileName string) (io.ReadCloser, string, error) { +func downloadFromURL(ctx context.Context, rawURL, fileName string, extraAllowedHost string) (io.ReadCloser, string, error) { // SSRF protection: reject internal/private URLs unless on the WeCom API allowlist. - if !isAllowedIMAPIHost(rawURL) { + if !isAllowedIMAPIHost(rawURL, extraAllowedHost) { if err := secutils.ValidateURLForSSRF(rawURL); err != nil { return nil, "", fmt.Errorf("URL rejected for security reasons: %v", err) } @@ -600,12 +649,16 @@ var allowedIMAPIHosts = []string{ } // isAllowedIMAPIHost returns true if rawURL points to a known IM platform API host. -func isAllowedIMAPIHost(rawURL string) bool { +// extraHost is an optional additional trusted hostname (e.g. from a private deployment). +func isAllowedIMAPIHost(rawURL string, extraHost string) bool { u, err := url.Parse(rawURL) if err != nil { return false } hostname := strings.ToLower(u.Hostname()) + if extraHost != "" && hostname == extraHost { + return true + } for _, allowed := range allowedIMAPIHosts { if hostname == allowed { return true @@ -623,20 +676,20 @@ func contentTypeToExt(ct string) string { ct = strings.ToLower(ct) mapping := map[string]string{ - "application/pdf": "pdf", - "application/msword": "doc", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", - "application/vnd.ms-excel": "xls", + "application/pdf": "pdf", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/vnd.ms-excel": "xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", - "application/vnd.ms-powerpoint": "ppt", + "application/vnd.ms-powerpoint": "ppt", "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx", - "text/plain": "txt", - "text/markdown": "md", - "text/csv": "csv", - "image/png": "png", - "image/jpeg": "jpg", - "image/gif": "gif", - "image/webp": "webp", + "text/plain": "txt", + "text/markdown": "md", + "text/csv": "csv", + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", } return mapping[ct] diff --git a/internal/im/wecom/ws_adapter.go b/internal/im/wecom/ws_adapter.go index 4cf686e95..a0dcb423e 100644 --- a/internal/im/wecom/ws_adapter.go +++ b/internal/im/wecom/ws_adapter.go @@ -82,7 +82,7 @@ func (a *WSAdapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) ( } // Download the (encrypted) file content - reader, fileName, err := downloadFromURL(ctx, msg.FileKey, fileName) + reader, fileName, err := downloadFromURL(ctx, msg.FileKey, fileName, a.client.extraAllowedHost) if err != nil { return nil, "", err }