From 225cb7e62b94c6731edb16da84b311de51c31ce3 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 28 Apr 2026 22:44:51 +0800 Subject: [PATCH] perf: catch error toast (#6849) * perf: catch error toast * fix: submodule * perf: ssrf * perf: readfiles --- .../content/self-host/upgrading/4-15/4150.mdx | 1 + document/data/doc-last-modified.json | 2 +- packages/service/common/api/axios.ts | 44 +++++++++---- packages/service/common/api/plusRequest.ts | 19 +++--- packages/service/common/api/serverRequest.ts | 17 +++-- packages/service/core/app/mcp.ts | 2 + packages/service/core/dataset/read.ts | 2 +- .../workflow/dispatch/ai/toolcall/toolCall.ts | 5 +- .../dispatch/ai/toolcall/tools/file.ts | 43 +++++++++---- .../service/support/outLink/runtime/utils.ts | 8 ++- .../service/test/common/api/axios.test.ts | 64 +++++++++++++++++++ .../service/test/common/system/utils.test.ts | 16 ++--- packages/service/test/core/app/mcp.test.ts | 2 +- pro | 2 +- projects/app/src/web/core/dataset/api.ts | 2 +- 15 files changed, 170 insertions(+), 59 deletions(-) diff --git a/document/content/self-host/upgrading/4-15/4150.mdx b/document/content/self-host/upgrading/4-15/4150.mdx index 77506ba604..6a9550c91e 100644 --- a/document/content/self-host/upgrading/4-15/4150.mdx +++ b/document/content/self-host/upgrading/4-15/4150.mdx @@ -15,6 +15,7 @@ description: 'FastGPT V4.15.0 更新说明' 2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。 3. 非管理员/访客,触发余额不足时候,提示优化。 4. 无创建权限时,隐藏模板功能。 +5. 加强第三方知识库请求的 SSRF 防护。 ## 🐛 修复 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 33de75d4c0..e6c74b4cac 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -252,7 +252,7 @@ "content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00", - "content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T21:27:07+08:00", + "content/self-host/upgrading/4-15/4150.mdx": "2026-04-28T21:35:13+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", diff --git a/packages/service/common/api/axios.ts b/packages/service/common/api/axios.ts index c0b46fe63f..f4ded1a67e 100644 --- a/packages/service/common/api/axios.ts +++ b/packages/service/common/api/axios.ts @@ -1,20 +1,42 @@ -import _, { type AxiosRequestConfig } from 'axios'; +import _, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; import { ProxyAgent } from 'proxy-agent'; import { isDevEnv } from '@fastgpt/global/common/system/constants'; +import { isInternalAddress, PRIVATE_URL_TEXT } from '../system/utils'; -export function createProxyAxios(config?: AxiosRequestConfig) { +const addSSRFInterceptor = (instance: AxiosInstance) => { + instance.interceptors.request.use(async (config) => { + const requestUrl = (() => { + try { + return new URL(config.url || '', config.baseURL).toString(); + } catch { + return; + } + })(); + if (!requestUrl) return config; + + if (await isInternalAddress(requestUrl)) { + return Promise.reject(new Error(PRIVATE_URL_TEXT)); + } + + return config; + }); + + return instance; +}; + +export function createProxyAxios(config?: AxiosRequestConfig, ssrfCheck = true) { const agent = new ProxyAgent(); - if (isDevEnv) { - return _.create(config); - } + const instance = isDevEnv + ? _.create(config) + : _.create({ + proxy: false, + httpAgent: agent, + httpsAgent: agent, + ...config + }); - return _.create({ - proxy: false, - httpAgent: agent, - httpsAgent: agent, - ...config - }); + return ssrfCheck ? addSSRFInterceptor(instance) : instance; } /** @see https://github.com/axios/axios/issues/4531 */ diff --git a/packages/service/common/api/plusRequest.ts b/packages/service/common/api/plusRequest.ts index 8c88128a17..ae8391c1a1 100644 --- a/packages/service/common/api/plusRequest.ts +++ b/packages/service/common/api/plusRequest.ts @@ -69,14 +69,17 @@ function responseError(err: any) { } /* 创建请求实例 */ -const instance = createProxyAxios({ - timeout: 60000, - headers: { - 'content-type': 'application/json', - 'Cache-Control': 'no-cache', - rootkey: process.env.ROOT_KEY - } -}); +const instance = createProxyAxios( + { + timeout: 60000, + headers: { + 'content-type': 'application/json', + 'Cache-Control': 'no-cache', + rootkey: process.env.ROOT_KEY + } + }, + false +); /* 请求拦截 */ instance.interceptors.request.use(requestStart, (err) => Promise.reject(err)); diff --git a/packages/service/common/api/serverRequest.ts b/packages/service/common/api/serverRequest.ts index fc5753ef98..583721ac54 100644 --- a/packages/service/common/api/serverRequest.ts +++ b/packages/service/common/api/serverRequest.ts @@ -60,13 +60,16 @@ function responseError(err: any) { } /* 创建请求实例 */ -const instance = createProxyAxios({ - timeout: 60000, // 超时时间 - headers: { - 'content-type': 'application/json', - 'Cache-Control': 'no-cache' - } -}); +const instance = createProxyAxios( + { + timeout: 60000, // 超时时间 + headers: { + 'content-type': 'application/json', + 'Cache-Control': 'no-cache' + } + }, + false +); export const serverRequestBaseUrl = `http://${SERVICE_LOCAL_HOST}`; /* 请求拦截 */ diff --git a/packages/service/core/app/mcp.ts b/packages/service/core/app/mcp.ts index 24d3e4ffeb..655360b06e 100644 --- a/packages/service/core/app/mcp.ts +++ b/packages/service/core/app/mcp.ts @@ -50,6 +50,8 @@ export class MCPClient { } private async doConnect(): Promise { + await assertMCPUrlNotInternal(this.url); + // 避免连接重复,强制关闭一次 await this.client.close().catch(() => {}); diff --git a/packages/service/core/dataset/read.ts b/packages/service/core/dataset/read.ts index 626c81d531..54d76fd40a 100644 --- a/packages/service/core/dataset/read.ts +++ b/packages/service/core/dataset/read.ts @@ -106,7 +106,7 @@ export const readFileRawTextByUrl = async ({ try { // 合并所有 chunks 为单个 buffer - const buffer = Buffer.concat(chunks); + const buffer = Buffer.concat(chunks as unknown as Uint8Array[]); // 立即清理 chunks 数组释放内存 chunks.length = 0; diff --git a/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts index 0be5862263..6c7e715fce 100644 --- a/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts +++ b/packages/service/core/workflow/dispatch/ai/toolcall/toolCall.ts @@ -314,8 +314,9 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise ({ id, url: allFiles.get(id)?.url! })), + toolCallId: call.id, teamId: workflowProps.runningUserInfo.teamId, tmbId: workflowProps.runningUserInfo.tmbId, customPdfParse: workflowProps.chatConfig?.fileSelectConfig?.customPdfParse, @@ -324,7 +325,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise { + const startTime = Date.now(); + const usages: ChatNodeUsageType[] = []; + const getFlowResponse = (nodeResponse: Record = {}): ChildResponseItemType => ({ + flowResponses: [ + { + ...nodeResponse, + moduleType: FlowNodeTypeEnum.readFiles, + moduleName: i18nT('chat:read_file'), + moduleLogo: ReadFileTooData.avatar, + id: toolCallId, + nodeId: toolCallId, + runningTime: +((Date.now() - startTime) / 1000).toFixed(2), + totalPoints: usages.reduce((sum, item) => sum + item.totalPoints, 0) + } + ], + flowUsages: usages, + runTimes: 0 + }); + try { - const usages: ChatNodeUsageType[] = []; const readFilesResult = await Promise.all( files.map(async ({ url, id }) => { try { @@ -93,21 +114,19 @@ export const dispatchReadFileTool = async ({ return { response, usages, - nodeResponse: { - moduleType: FlowNodeTypeEnum.readFiles, - moduleName: i18nT('chat:read_file') - } + flowResponse: getFlowResponse() }; } catch (error) { logger.error('[File Read] Compression failed, using original content', { error }); + const response = `Failed to read file: ${getErrText(error)}`; + const nodeResponse = { + errorText: response + }; + return { - response: `Failed to read file: ${getErrText(error)}`, - usages: [], - nodeResponse: { - moduleType: FlowNodeTypeEnum.readFiles, - moduleName: i18nT('chat:read_file'), - errorText: `Failed to read file: ${getErrText(error)}` - } + response, + usages, + flowResponse: getFlowResponse(nodeResponse) }; } }; diff --git a/packages/service/support/outLink/runtime/utils.ts b/packages/service/support/outLink/runtime/utils.ts index d5edccd0f5..9cde1b1ff4 100644 --- a/packages/service/support/outLink/runtime/utils.ts +++ b/packages/service/support/outLink/runtime/utils.ts @@ -23,7 +23,10 @@ import { getLogger, LogCategories } from '../../../common/logger'; import { appendRedisCache } from '../../../common/redis/cache'; import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; import { getUsageSourceByPublishChannel } from '@fastgpt/global/support/wallet/usage/tools'; -import { getChatSourceByPublishChannel } from '@fastgpt/global/core/chat/utils'; +import { + getChatSourceByPublishChannel, + removeAIResponseCite +} from '@fastgpt/global/core/chat/utils'; import { WORKFLOW_MAX_RUN_TIMES } from '../../../core/workflow/constants'; import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { MongoChat } from '../../../core/chat/chatSchema'; @@ -226,7 +229,8 @@ export async function outlinkInvokeChat({ }); // Format results - let responseContent = assistantResponses + const formatAssistantResponses = removeAIResponseCite(assistantResponses, false); + let responseContent = formatAssistantResponses .map((response) => { return response.text?.content; }) diff --git a/packages/service/test/common/api/axios.test.ts b/packages/service/test/common/api/axios.test.ts index a63ed12855..876addc251 100644 --- a/packages/service/test/common/api/axios.test.ts +++ b/packages/service/test/common/api/axios.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createProxyAxios } from '@fastgpt/service/common/api/axios'; +import { PRIVATE_URL_TEXT } from '@fastgpt/service/common/system/utils'; type AxiosRequestConfig = { timeout?: number; @@ -86,6 +87,69 @@ describe('axios.ts', () => { expect(typeof instance.delete).toBe('function'); expect(typeof instance.request).toBe('function'); }); + + it('应该在请求前阻止内网地址', async () => { + const adapter = vi.fn().mockResolvedValue({ + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config: {} + }); + const instance = createProxyAxios({ adapter }); + + await expect(instance.get('http://127.0.0.1/admin')).rejects.toThrow(PRIVATE_URL_TEXT); + expect(adapter).not.toHaveBeenCalled(); + }); + + it('应该校验 baseURL 和相对路径合成后的地址', async () => { + const adapter = vi.fn().mockResolvedValue({ + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config: {} + }); + const instance = createProxyAxios({ + baseURL: 'http://169.254.169.254', + adapter + }); + + await expect(instance.get('/latest/meta-data/')).rejects.toThrow(PRIVATE_URL_TEXT); + expect(adapter).not.toHaveBeenCalled(); + }); + + it('应该允许公网地址继续进入 adapter', async () => { + const adapter = vi.fn().mockResolvedValue({ + data: { ok: true }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} + }); + const instance = createProxyAxios({ adapter }); + + const response = await instance.get('https://example.com/api'); + + expect(response.data).toEqual({ ok: true }); + expect(adapter).toHaveBeenCalled(); + }); + + it('应该允许显式关闭 SSRF 检查', async () => { + const adapter = vi.fn().mockResolvedValue({ + data: { ok: true }, + status: 200, + statusText: 'OK', + headers: {}, + config: {} + }); + const instance = createProxyAxios({ adapter }, false); + + const response = await instance.get('http://127.0.0.1/admin'); + + expect(response.data).toEqual({ ok: true }); + expect(adapter).toHaveBeenCalled(); + }); }); describe('axios 导出实例', () => { diff --git a/packages/service/test/common/system/utils.test.ts b/packages/service/test/common/system/utils.test.ts index 17eb908140..8b5294e570 100644 --- a/packages/service/test/common/system/utils.test.ts +++ b/packages/service/test/common/system/utils.test.ts @@ -1,15 +1,5 @@ import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { isInternalAddress } from '@fastgpt/service/common/system/utils'; - -// Mock dns module -vi.mock('dns/promises', () => ({ - default: { - resolve4: vi.fn(), - resolve6: vi.fn() - } -})); - -// Import mocked dns after mock setup import dns from 'dns/promises'; describe('SSRF Protection - isInternalAddress', () => { @@ -17,8 +7,10 @@ describe('SSRF Protection - isInternalAddress', () => { beforeEach(() => { process.env.CHECK_INTERNAL_IP = 'true'; - // 清除所有 mock - vi.clearAllMocks(); + // 重建 DNS spy,避免真实 DNS 解析和用例之间的 mock 实现串味 + vi.restoreAllMocks(); + vi.spyOn(dns, 'resolve4').mockRejectedValue(new Error('No A records')); + vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records')); }); afterEach(() => { diff --git a/packages/service/test/core/app/mcp.test.ts b/packages/service/test/core/app/mcp.test.ts index bd1fff7e20..83ac60d2b5 100644 --- a/packages/service/test/core/app/mcp.test.ts +++ b/packages/service/test/core/app/mcp.test.ts @@ -36,7 +36,7 @@ beforeEach(() => { }); describe('MCPClient', () => { - const config = { url: 'http://localhost:3000/mcp', headers: { Authorization: 'Bearer test' } }; + const config = { url: 'https://example.com/mcp', headers: { Authorization: 'Bearer test' } }; describe('assertMCPUrlNotInternal', () => { it('should reject localhost MCP endpoints', async () => { diff --git a/pro b/pro index 70380fe539..1a56dfc0d3 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 70380fe53914fff72e256be0e3b4eb649ae1ad90 +Subproject commit 1a56dfc0d3c2fc1dc3e4a2f23ad1847926c804a8 diff --git a/projects/app/src/web/core/dataset/api.ts b/projects/app/src/web/core/dataset/api.ts index a31b4e5a0a..374aec1971 100644 --- a/projects/app/src/web/core/dataset/api.ts +++ b/projects/app/src/web/core/dataset/api.ts @@ -48,7 +48,7 @@ export const delDatasetById = (id: string) => DELETE(`/core/dataset/delete?id=${ export const postDatasetSync = (data: PostDatasetSyncParams) => POST(`/proApi/core/dataset/datasetSync`, data, { timeout: 600000 - }).catch(); + }); export const postCreateDatasetFolder = (data: CreateDatasetFolderBody) => POST(`/core/dataset/folder/create`, data);