mirror of
https://github.com/labring/FastGPT.git
synced 2026-05-08 01:08:43 +08:00
perf: catch error toast (#6849)
* perf: catch error toast * fix: submodule * perf: ssrf * perf: readfiles
This commit is contained in:
@@ -15,6 +15,7 @@ description: 'FastGPT V4.15.0 更新说明'
|
||||
2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。
|
||||
3. 非管理员/访客,触发余额不足时候,提示优化。
|
||||
4. 无创建权限时,隐藏模板功能。
|
||||
5. 加强第三方知识库请求的 SSRF 防护。
|
||||
|
||||
## 🐛 修复
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
/* 请求拦截 */
|
||||
|
||||
@@ -50,6 +50,8 @@ export class MCPClient {
|
||||
}
|
||||
|
||||
private async doConnect(): Promise<Client> {
|
||||
await assertMCPUrlNotInternal(this.url);
|
||||
|
||||
// 避免连接重复,强制关闭一次
|
||||
await this.client.close().catch(() => {});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user