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 @@
+
@@ -196,6 +201,11 @@
+
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
}