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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user