perf: catch error toast (#6849)

* perf: catch error toast

* fix: submodule

* perf: ssrf

* perf: readfiles
This commit is contained in:
Archer
2026-04-28 22:44:51 +08:00
committed by GitHub
parent 9f5e1d08b7
commit 225cb7e62b
15 changed files with 170 additions and 59 deletions
@@ -15,6 +15,7 @@ description: 'FastGPT V4.15.0 更新说明'
2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。
3. 非管理员/访客,触发余额不足时候,提示优化。
4. 无创建权限时,隐藏模板功能。
5. 加强第三方知识库请求的 SSRF 防护。
## 🐛 修复
+1 -1
View File
@@ -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",
+33 -11
View File
@@ -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 */
+11 -8
View File
@@ -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));
+10 -7
View File
@@ -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}`;
/* 请求拦截 */
+2
View File
@@ -50,6 +50,8 @@ export class MCPClient {
}
private async doConnect(): Promise<Client> {
await assertMCPUrlNotInternal(this.url);
// 避免连接重复,强制关闭一次
await this.client.close().catch(() => {});
+1 -1
View File
@@ -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;
@@ -314,8 +314,9 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
return { response, flowResponse };
} else if (toolInfo.type === 'file') {
const { ids } = ReadFileToolParamsSchema.parse(parseJsonArgs(call.function.arguments));
const { response, usages, nodeResponse } = await dispatchReadFileTool({
const { response, usages, flowResponse } = await dispatchReadFileTool({
files: ids.map((id) => ({ 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<Respo
return {
response,
usages,
nodeResponse
flowResponse
};
} else {
const toolNode = toolInfo.rawData;
@@ -7,6 +7,7 @@ import { LogCategories } from '../../../../../../common/logger';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { i18nT } from '../../../../../../../web/i18n/utils';
import z from 'zod';
import type { ChildResponseItemType } from '../type';
const logger = getLogger(LogCategories.MODULE.AI.TOOL_CALL);
@@ -39,6 +40,7 @@ export const ReadFileToolParamsSchema = z.object({
});
type FileReadParams = {
files: { id: string; url: string }[];
toolCallId: string;
teamId: string;
tmbId: string;
@@ -47,13 +49,32 @@ type FileReadParams = {
};
export const dispatchReadFileTool = async ({
files,
toolCallId,
teamId,
tmbId,
customPdfParse,
usageId
}: FileReadParams) => {
const startTime = Date.now();
const usages: ChatNodeUsageType[] = [];
const getFlowResponse = (nodeResponse: Record<string, any> = {}): 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)
};
}
};
@@ -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<T extends OutlinkAppType>({
});
// Format results
let responseContent = assistantResponses
const formatAssistantResponses = removeAIResponseCite(assistantResponses, false);
let responseContent = formatAssistantResponses
.map((response) => {
return response.text?.content;
})
@@ -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 导出实例', () => {
@@ -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(() => {
+1 -1
View File
@@ -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 () => {
+1 -1
Submodule pro updated: 70380fe539...1a56dfc0d3
+1 -1
View File
@@ -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);