fix(initialization): surface upstream and SSRF errors verbatim in test-connection responses

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 "<hint>:<raw err>" 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 未通过安全校验: <reason>" 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.
This commit is contained in:
wizardchen
2026-05-17 15:10:42 +08:00
committed by lyingbug
parent 31d5566a50
commit ce8969ab14
7 changed files with 107 additions and 41 deletions
+12 -9
View File
@@ -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
+44 -24
View File
@@ -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: "<hint>:<raw err>".
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
}
+1 -1
View File
@@ -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
}
+2 -3
View File
@@ -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
}
}
+2 -3
View File
@@ -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
}
}
+1 -1
View File
@@ -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
}
+45
View File
@@ -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.