From ce8969ab14f042449aba6a697729d2efffa5cf20 Mon Sep 17 00:00:00 2001 From: wizardchen Date: Sun, 17 May 2026 15:10:42 +0800 Subject: [PATCH] fix(initialization): surface upstream and SSRF errors verbatim in test-connection responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-connection's "连接失败" was nearly useless for debugging because two layers conspired to swallow the actual upstream error: Backend swallow --------------- checkChatModelConnection and CheckASRModel pattern-matched on the upstream error string (401 / 404 / timeout / ...) and replaced it with a fixed Chinese hint. The original SDK message (request URL, response body, provider's error code description) was discarded. Refactor: extract classifyConnectionError(errMsg) — returns just the short Chinese hint. Callers combine ":" so the hint indicates "where to start looking" and the raw error shows "what actually happened". The status-400 special-case (endpoint reachable, parameter mismatch only) still returns success. Frontend swallow ---------------- ModelEditorDialog.testConnection ignored result.message on success paths (fine), but also overwrote it with a generic i18n string on failure paths and 4xx rejections — even though axios's interceptor already lifts the backend's error.message to error.message. Failure branch now uses error?.message || result.message as the toast content; i18n fallback only when neither is present. SSRF allowlist hint ------------------- SSRF validation failures previously returned "Base URL 未通过安全校验: " with no mention of how to recover (the allowlist is server-side, not configurable in the UI). Operators hit it and were stuck. New utils.FormatSSRFError(label, rawURL, err) wraps the error with guidance to add the host to SSRF_WHITELIST_EXTRA (the operator-facing append-only escape hatch — SSRF_WHITELIST itself is the project's baseline). The hint echoes the failed host so it can be copy-pasted straight into the env var: Base URL 未通过安全校验:DNS resolution failed for hostname test.local.com ... 如该地址确实可信,请联系运维在服务端环境变量 SSRF_WHITELIST_EXTRA 中加入该主机(支持精确域名 / *.example.com 通配 / IP / CIDR),示例:SSRF_WHITELIST_EXTRA=test.local.com, *.example.com,10.0.0.0/8 All SSRF call sites switch to the helper: initialization (CheckRemoteModel / TestEmbedding / CheckRerank / CheckASR / TestMultimodal / batch validation), model CRUD, mcp_service CRUD, knowledge URL ingest, system ReconnectDocReader. --- frontend/src/components/ModelEditorDialog.vue | 21 +++--- internal/handler/initialization.go | 68 ++++++++++++------- internal/handler/knowledge.go | 2 +- internal/handler/mcp_service.go | 5 +- internal/handler/model.go | 5 +- internal/handler/system.go | 2 +- internal/utils/security.go | 45 ++++++++++++ 7 files changed, 107 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/ModelEditorDialog.vue b/frontend/src/components/ModelEditorDialog.vue index 4dd41a08..e5caceaf 100644 --- a/frontend/src/components/ModelEditorDialog.vue +++ b/frontend/src/components/ModelEditorDialog.vue @@ -1034,24 +1034,27 @@ const checkRemoteAPI = async () => { remoteChecked.value = true remoteAvailable.value = result.available || false - // Always use i18n for display; backend message is for debugging only - if (result.message) { - console.debug('Backend message:', result.message) - } - remoteMessage.value = result.available - ? t('model.editor.connectionSuccess') - : t('model.editor.connectionFailed') - + // 之前这里把 backend 的错误 message 只丢到 console.debug,用户只能 + // 看到通用的 "连接失败" toast,根本看不出是 401 / 404 / 模型不存在 + // 还是别的什么。改成:成功时用 i18n 通用提示;失败时直接展示后端 + // 给到的具体原因(已经在后端 classifyConnectionError 中包了一层 + // 易读的中文 hint + 原始 SDK 报错),方便排查。 if (result.available) { + remoteMessage.value = t('model.editor.connectionSuccess') MessagePlugin.success(remoteMessage.value) } else { + remoteMessage.value = result.message || t('model.editor.connectionFailed') + console.debug('Backend message:', result.message) MessagePlugin.error(remoteMessage.value) } } catch (error: any) { console.error('Remote API check failed:', error) remoteChecked.value = true remoteAvailable.value = false - remoteMessage.value = t('model.editor.connectionConfigError') + // 后端 4xx/5xx(如 SSRF 校验失败)会走到这里。axios 拦截器把后端 + // { error: { message: "..." } } 提到了 error.message,里面已经包含 + // 易读 hint + 原因,直接展示出来,比通用 "请检查配置" 有用得多。 + remoteMessage.value = error?.message || t('model.editor.connectionConfigError') MessagePlugin.error(remoteMessage.value) } finally { checking.value = false diff --git a/internal/handler/initialization.go b/internal/handler/initialization.go index 5fcac1e3..79e485b1 100644 --- a/internal/handler/initialization.go +++ b/internal/handler/initialization.go @@ -544,7 +544,7 @@ func (h *InitializationHandler) validateInitializationConfigs(ctx context.Contex if u.url != "" { if err := utils.ValidateURLForSSRF(u.url); err != nil { logger.Warnf(ctx, "SSRF validation failed for %s: %v", u.label, err) - return errors.NewBadRequestError(fmt.Sprintf("%s 未通过安全校验: %v", u.label, err)) + return errors.NewBadRequestError(utils.FormatSSRFError(u.label, u.url, err)) } } } @@ -1659,7 +1659,7 @@ func (h *InitializationHandler) CheckRemoteModel(c *gin.Context) { if err := utils.ValidateURLForSSRF(req.BaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for remote model BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(utils.FormatSSRFError("Base URL", req.BaseURL, err))) return } appID, appSecret, ok := h.resolveTenantWeKnoraCloudCreds(ctx) @@ -1714,7 +1714,7 @@ func (h *InitializationHandler) TestEmbeddingModel(c *gin.Context) { if req.BaseURL != "" { if err := utils.ValidateURLForSSRF(req.BaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for embedding BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(utils.FormatSSRFError("Base URL", req.BaseURL, err))) return } } @@ -1771,6 +1771,29 @@ func (h *InitializationHandler) TestEmbeddingModel(c *gin.Context) { }) } +// classifyConnectionError maps an upstream error string to a short +// human-readable hint in Chinese. Callers should always combine the hint +// with the raw error message (e.g. fmt.Sprintf("%s:%v", hint, err)) so +// the operator can still see what URL / response body the SDK actually +// got — the hint is for "where to start looking", the raw error is for +// "what actually happened". +func classifyConnectionError(errMsg string) string { + switch { + case strings.Contains(errMsg, "401") || strings.Contains(errMsg, "unauthorized"): + return "认证失败,请检查API Key" + case strings.Contains(errMsg, "403") || strings.Contains(errMsg, "forbidden"): + return "权限不足,请检查API Key权限" + case strings.Contains(errMsg, "404") || strings.Contains(errMsg, "not found"): + return "API端点不存在,请检查Base URL" + case strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "context deadline exceeded"): + return "连接超时,请检查网络连接" + case strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "no such host") || strings.Contains(errMsg, "dial tcp"): + return "无法连接到服务器,请检查Base URL" + default: + return "连接失败" + } +} + // checkChatModelConnection 使用 chat 模块做一次最小化调用来测试连通性与鉴权。 // 与生产路径走完全相同的 ConfigFromModel → NewChat 流程,因此 CustomHeaders、 // ExtraConfig、Provider 等字段都会被正确透传。 @@ -1791,22 +1814,17 @@ func (h *InitializationHandler) checkChatModelConnection( _, err = chatInstance.Chat(ctx, testMessages, testOptions) if err != nil { errMsg := err.Error() - // 根据错误类型返回不同的错误信息 - if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "unauthorized") { - return false, "认证失败,请检查API Key" - } else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "forbidden") { - return false, "权限不足,请检查API Key权限:" + errMsg - } else if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "not found") { - return false, "API端点不存在,请检查Base URL" - } else if strings.Contains(errMsg, "timeout") { - return false, "连接超时,请检查网络连接" - } else if strings.Contains(errMsg, "status code: 400") { - // 400 错误说明 API 端点可达、认证通过,只是请求参数不兼容(如 max_tokens vs max_completion_tokens) - // 视为连接成功 + // 400 = endpoint reachable + auth ok, just a parameter mismatch + // (e.g. max_tokens vs max_completion_tokens). Treat as success. + if strings.Contains(errMsg, "status code: 400") { return true, "连接正常,模型可用" - } else { - return false, fmt.Sprintf("连接失败: %v", err) } + // For every other failure mode we surface a human-readable hint + // AND the upstream error verbatim. Swallowing the underlying + // message used to hide things like the actual URL the SDK + // tried, response body, etc. — making remote debugging nearly + // impossible. Format: ":". + return false, fmt.Sprintf("%s:%v", classifyConnectionError(errMsg), err) } // 连接成功,模型可用 @@ -1866,7 +1884,7 @@ func (h *InitializationHandler) CheckRerankModel(c *gin.Context) { if err := utils.ValidateURLForSSRF(req.BaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for rerank BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(utils.FormatSSRFError("Base URL", req.BaseURL, err))) return } @@ -1924,7 +1942,7 @@ func (h *InitializationHandler) CheckASRModel(c *gin.Context) { if err := utils.ValidateURLForSSRF(req.BaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for ASR BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(utils.FormatSSRFError("Base URL", req.BaseURL, err))) return } @@ -1954,19 +1972,21 @@ func (h *InitializationHandler) CheckASRModel(c *gin.Context) { if err != nil { errMsg := err.Error() + // Always include the raw upstream error after the hint — see + // classifyConnectionError comment for rationale. switch { case strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Unauthorized") || strings.Contains(errMsg, "authentication"): available = false - message = "认证失败,请检查API Key" + message = fmt.Sprintf("认证失败,请检查API Key:%s", errMsg) case strings.Contains(errMsg, "404") || strings.Contains(errMsg, "Not Found"): available = false - message = "API端点不存在,请检查Base URL" + message = fmt.Sprintf("API端点不存在,请检查Base URL:%s", errMsg) case strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "no such host") || strings.Contains(errMsg, "dial tcp"): available = false - message = "无法连接到服务器,请检查Base URL" + message = fmt.Sprintf("无法连接到服务器,请检查Base URL:%s", errMsg) case strings.Contains(errMsg, "model") && strings.Contains(errMsg, "not found"): available = false - message = "模型不存在,请检查模型名称" + message = fmt.Sprintf("模型不存在,请检查模型名称:%s", errMsg) default: logger.Infof(ctx, "ASR check got non-fatal error (endpoint reachable): %v", err) available = true @@ -2058,7 +2078,7 @@ func (h *InitializationHandler) TestMultimodalFunction(c *gin.Context) { // SSRF validation for VLM BaseURL if err := utils.ValidateURLForSSRF(req.VLMBaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for VLM BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("VLM Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(utils.FormatSSRFError("VLM Base URL", req.VLMBaseURL, err))) return } diff --git a/internal/handler/knowledge.go b/internal/handler/knowledge.go index 0b7d0b10..7d88b6e0 100644 --- a/internal/handler/knowledge.go +++ b/internal/handler/knowledge.go @@ -399,7 +399,7 @@ func (h *KnowledgeHandler) CreateKnowledgeFromURL(c *gin.Context) { // SSRF validation for user-supplied URL if err := secutils.ValidateURLForSSRF(req.URL); err != nil { logger.Warnf(ctx, "SSRF validation failed for knowledge URL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("URL", req.URL, err))) return } diff --git a/internal/handler/mcp_service.go b/internal/handler/mcp_service.go index 2e2e060a..7c7f6466 100644 --- a/internal/handler/mcp_service.go +++ b/internal/handler/mcp_service.go @@ -3,7 +3,6 @@ package handler import ( "encoding/json" stderrors "errors" - "fmt" "net/http" "strings" @@ -71,7 +70,7 @@ func (h *MCPServiceHandler) CreateMCPService(c *gin.Context) { if service.URL != nil && *service.URL != "" { if err := secutils.ValidateURLForSSRF(*service.URL); err != nil { logger.Warnf(ctx, "SSRF validation failed for MCP service URL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("MCP service URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("MCP service URL", *service.URL, err))) return } } @@ -234,7 +233,7 @@ func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) { if service.URL != nil && *service.URL != "" { if err := secutils.ValidateURLForSSRF(*service.URL); err != nil { logger.Warnf(ctx, "SSRF validation failed for MCP service URL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("MCP service URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("MCP service URL", *service.URL, err))) return } } diff --git a/internal/handler/model.go b/internal/handler/model.go index 89147e83..dfebf2d3 100644 --- a/internal/handler/model.go +++ b/internal/handler/model.go @@ -1,7 +1,6 @@ package handler import ( - "fmt" "net/http" "github.com/Tencent/WeKnora/internal/application/service" @@ -82,7 +81,7 @@ func (h *ModelHandler) CreateModel(c *gin.Context) { if req.Parameters.BaseURL != "" { if err := secutils.ValidateURLForSSRF(req.Parameters.BaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for model BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("Base URL", req.Parameters.BaseURL, err))) return } } @@ -263,7 +262,7 @@ func (h *ModelHandler) UpdateModel(c *gin.Context) { if req.Parameters.BaseURL != "" { if err := secutils.ValidateURLForSSRF(req.Parameters.BaseURL); err != nil { logger.Warnf(ctx, "SSRF validation failed for model BaseURL: %v", err) - c.Error(errors.NewBadRequestError(fmt.Sprintf("Base URL 未通过安全校验: %v", err))) + c.Error(errors.NewBadRequestError(secutils.FormatSSRFError("Base URL", req.Parameters.BaseURL, err))) return } } diff --git a/internal/handler/system.go b/internal/handler/system.go index 31b331e6..f12f41af 100644 --- a/internal/handler/system.go +++ b/internal/handler/system.go @@ -201,7 +201,7 @@ func (h *SystemHandler) ReconnectDocReader(c *gin.Context) { // SSRF validation for docreader address if err := secutils.ValidateURLForSSRF(addr); err != nil { logger.Warnf(c.Request.Context(), "SSRF validation failed for docreader addr: %v", err) - c.JSON(400, gin.H{"code": 1, "msg": fmt.Sprintf("地址未通过安全校验: %v", err)}) + c.JSON(400, gin.H{"code": 1, "msg": secutils.FormatSSRFError("DocReader 地址", addr, err)}) return } diff --git a/internal/utils/security.go b/internal/utils/security.go index 729bec29..75a22817 100644 --- a/internal/utils/security.go +++ b/internal/utils/security.go @@ -974,6 +974,51 @@ func resetSSRFWhitelistForTest() { ssrfWhitelist = nil } +// FormatSSRFError takes the error returned by ValidateURLForSSRF and wraps +// it with operator guidance — specifically how to add a host to the SSRF +// allow-list. Without this hint, users hit "Base URL 未通过安全校验" with +// no idea how to recover (the allowlist is configured server-side, not +// in the UI). The hint references SSRF_WHITELIST_EXTRA rather than +// SSRF_WHITELIST because the latter is the project's baseline list and +// EXTRA is the operator's append-only escape hatch. +// +// `label` is a short noun describing the URL field that failed, e.g. +// "Base URL" or "VLM Base URL". The function returns an empty string for +// a nil err so callers can use it inline without guarding. +func FormatSSRFError(label, rawURL string, err error) string { + if err == nil { + return "" + } + host := rawURL + if parsed, perr := parseHostForHint(rawURL); perr == nil && parsed != "" { + host = parsed + } + return fmt.Sprintf( + "%s 未通过安全校验:%v。如该地址确实可信,请联系运维在服务端环境变量 "+ + "SSRF_WHITELIST_EXTRA 中加入该主机(支持精确域名 / *.example.com 通配 / IP / CIDR),"+ + "示例:SSRF_WHITELIST_EXTRA=%s,*.example.com,10.0.0.0/8", + label, err, host, + ) +} + +// parseHostForHint extracts a hostname from rawURL purely so we can echo +// it back inside the SSRF hint. Best-effort — returns ("", err) for +// completely unparseable input and the caller falls back to the raw URL. +func parseHostForHint(rawURL string) (string, error) { + if rawURL == "" { + return "", fmt.Errorf("empty url") + } + norm := rawURL + if !strings.Contains(norm, "://") { + norm = "https://" + norm + } + u, err := url.Parse(norm) + if err != nil { + return "", err + } + return u.Hostname(), nil +} + // ValidateURLForSSRF is the centralised entry-point that all handlers should // call to validate a user-supplied URL. It first checks the SSRF_WHITELIST; // whitelisted hosts skip the full isSSRFSafeURL check.