Commit 225cb7e6 by Archer Committed by GitHub

perf: catch error toast (#6849)

* perf: catch error toast

* fix: submodule

* perf: ssrf

* perf: readfiles
parent 9f5e1d08
...@@ -15,6 +15,7 @@ description: 'FastGPT V4.15.0 更新说明' ...@@ -15,6 +15,7 @@ description: 'FastGPT V4.15.0 更新说明'
2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。 2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。
3. 非管理员/访客,触发余额不足时候,提示优化。 3. 非管理员/访客,触发余额不足时候,提示优化。
4. 无创建权限时,隐藏模板功能。 4. 无创建权限时,隐藏模板功能。
5. 加强第三方知识库请求的 SSRF 防护。
## 🐛 修复 ## 🐛 修复
......
...@@ -252,7 +252,7 @@ ...@@ -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/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.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-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.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/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
import _, { type AxiosRequestConfig } from 'axios'; import _, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
import { ProxyAgent } from 'proxy-agent'; import { ProxyAgent } from 'proxy-agent';
import { isDevEnv } from '@fastgpt/global/common/system/constants'; 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) => {
const agent = new ProxyAgent(); instance.interceptors.request.use(async (config) => {
const requestUrl = (() => {
try {
return new URL(config.url || '', config.baseURL).toString();
} catch {
return;
}
})();
if (!requestUrl) return config;
if (isDevEnv) { if (await isInternalAddress(requestUrl)) {
return _.create(config); return Promise.reject(new Error(PRIVATE_URL_TEXT));
} }
return _.create({ return config;
});
return instance;
};
export function createProxyAxios(config?: AxiosRequestConfig, ssrfCheck = true) {
const agent = new ProxyAgent();
const instance = isDevEnv
? _.create(config)
: _.create({
proxy: false, proxy: false,
httpAgent: agent, httpAgent: agent,
httpsAgent: agent, httpsAgent: agent,
...config ...config
}); });
return ssrfCheck ? addSSRFInterceptor(instance) : instance;
} }
/** @see https://github.com/axios/axios/issues/4531 */ /** @see https://github.com/axios/axios/issues/4531 */
......
...@@ -69,14 +69,17 @@ function responseError(err: any) { ...@@ -69,14 +69,17 @@ function responseError(err: any) {
} }
/* 创建请求实例 */ /* 创建请求实例 */
const instance = createProxyAxios({ const instance = createProxyAxios(
{
timeout: 60000, timeout: 60000,
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
rootkey: process.env.ROOT_KEY rootkey: process.env.ROOT_KEY
} }
}); },
false
);
/* 请求拦截 */ /* 请求拦截 */
instance.interceptors.request.use(requestStart, (err) => Promise.reject(err)); instance.interceptors.request.use(requestStart, (err) => Promise.reject(err));
......
...@@ -60,13 +60,16 @@ function responseError(err: any) { ...@@ -60,13 +60,16 @@ function responseError(err: any) {
} }
/* 创建请求实例 */ /* 创建请求实例 */
const instance = createProxyAxios({ const instance = createProxyAxios(
{
timeout: 60000, // 超时时间 timeout: 60000, // 超时时间
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
'Cache-Control': 'no-cache' 'Cache-Control': 'no-cache'
} }
}); },
false
);
export const serverRequestBaseUrl = `http://${SERVICE_LOCAL_HOST}`; export const serverRequestBaseUrl = `http://${SERVICE_LOCAL_HOST}`;
/* 请求拦截 */ /* 请求拦截 */
......
...@@ -50,6 +50,8 @@ export class MCPClient { ...@@ -50,6 +50,8 @@ export class MCPClient {
} }
private async doConnect(): Promise<Client> { private async doConnect(): Promise<Client> {
await assertMCPUrlNotInternal(this.url);
// 避免连接重复,强制关闭一次 // 避免连接重复,强制关闭一次
await this.client.close().catch(() => {}); await this.client.close().catch(() => {});
......
...@@ -106,7 +106,7 @@ export const readFileRawTextByUrl = async ({ ...@@ -106,7 +106,7 @@ export const readFileRawTextByUrl = async ({
try { try {
// 合并所有 chunks 为单个 buffer // 合并所有 chunks 为单个 buffer
const buffer = Buffer.concat(chunks); const buffer = Buffer.concat(chunks as unknown as Uint8Array[]);
// 立即清理 chunks 数组释放内存 // 立即清理 chunks 数组释放内存
chunks.length = 0; chunks.length = 0;
......
...@@ -314,8 +314,9 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo ...@@ -314,8 +314,9 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
return { response, flowResponse }; return { response, flowResponse };
} else if (toolInfo.type === 'file') { } else if (toolInfo.type === 'file') {
const { ids } = ReadFileToolParamsSchema.parse(parseJsonArgs(call.function.arguments)); 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! })), files: ids.map((id) => ({ id, url: allFiles.get(id)?.url! })),
toolCallId: call.id,
teamId: workflowProps.runningUserInfo.teamId, teamId: workflowProps.runningUserInfo.teamId,
tmbId: workflowProps.runningUserInfo.tmbId, tmbId: workflowProps.runningUserInfo.tmbId,
customPdfParse: workflowProps.chatConfig?.fileSelectConfig?.customPdfParse, customPdfParse: workflowProps.chatConfig?.fileSelectConfig?.customPdfParse,
...@@ -324,7 +325,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo ...@@ -324,7 +325,7 @@ export const runToolCall = async (props: DispatchToolModuleProps): Promise<Respo
return { return {
response, response,
usages, usages,
nodeResponse flowResponse
}; };
} else { } else {
const toolNode = toolInfo.rawData; const toolNode = toolInfo.rawData;
......
...@@ -7,6 +7,7 @@ import { LogCategories } from '../../../../../../common/logger'; ...@@ -7,6 +7,7 @@ import { LogCategories } from '../../../../../../common/logger';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import { i18nT } from '../../../../../../../web/i18n/utils'; import { i18nT } from '../../../../../../../web/i18n/utils';
import z from 'zod'; import z from 'zod';
import type { ChildResponseItemType } from '../type';
const logger = getLogger(LogCategories.MODULE.AI.TOOL_CALL); const logger = getLogger(LogCategories.MODULE.AI.TOOL_CALL);
...@@ -39,6 +40,7 @@ export const ReadFileToolParamsSchema = z.object({ ...@@ -39,6 +40,7 @@ export const ReadFileToolParamsSchema = z.object({
}); });
type FileReadParams = { type FileReadParams = {
files: { id: string; url: string }[]; files: { id: string; url: string }[];
toolCallId: string;
teamId: string; teamId: string;
tmbId: string; tmbId: string;
...@@ -47,13 +49,32 @@ type FileReadParams = { ...@@ -47,13 +49,32 @@ type FileReadParams = {
}; };
export const dispatchReadFileTool = async ({ export const dispatchReadFileTool = async ({
files, files,
toolCallId,
teamId, teamId,
tmbId, tmbId,
customPdfParse, customPdfParse,
usageId usageId
}: FileReadParams) => { }: FileReadParams) => {
try { const startTime = Date.now();
const usages: ChatNodeUsageType[] = []; 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 readFilesResult = await Promise.all( const readFilesResult = await Promise.all(
files.map(async ({ url, id }) => { files.map(async ({ url, id }) => {
try { try {
...@@ -93,21 +114,19 @@ export const dispatchReadFileTool = async ({ ...@@ -93,21 +114,19 @@ export const dispatchReadFileTool = async ({
return { return {
response, response,
usages, usages,
nodeResponse: { flowResponse: getFlowResponse()
moduleType: FlowNodeTypeEnum.readFiles,
moduleName: i18nT('chat:read_file')
}
}; };
} catch (error) { } catch (error) {
logger.error('[File Read] Compression failed, using original content', { error }); logger.error('[File Read] Compression failed, using original content', { error });
const response = `Failed to read file: ${getErrText(error)}`;
const nodeResponse = {
errorText: response
};
return { return {
response: `Failed to read file: ${getErrText(error)}`, response,
usages: [], usages,
nodeResponse: { flowResponse: getFlowResponse(nodeResponse)
moduleType: FlowNodeTypeEnum.readFiles,
moduleName: i18nT('chat:read_file'),
errorText: `Failed to read file: ${getErrText(error)}`
}
}; };
} }
}; };
...@@ -23,7 +23,10 @@ import { getLogger, LogCategories } from '../../../common/logger'; ...@@ -23,7 +23,10 @@ import { getLogger, LogCategories } from '../../../common/logger';
import { appendRedisCache } from '../../../common/redis/cache'; import { appendRedisCache } from '../../../common/redis/cache';
import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils';
import { getUsageSourceByPublishChannel } from '@fastgpt/global/support/wallet/usage/tools'; 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 { WORKFLOW_MAX_RUN_TIMES } from '../../../core/workflow/constants';
import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { mongoSessionRun } from '../../../common/mongo/sessionRun';
import { MongoChat } from '../../../core/chat/chatSchema'; import { MongoChat } from '../../../core/chat/chatSchema';
...@@ -226,7 +229,8 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({ ...@@ -226,7 +229,8 @@ export async function outlinkInvokeChat<T extends OutlinkAppType>({
}); });
// Format results // Format results
let responseContent = assistantResponses const formatAssistantResponses = removeAIResponseCite(assistantResponses, false);
let responseContent = formatAssistantResponses
.map((response) => { .map((response) => {
return response.text?.content; return response.text?.content;
}) })
......
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createProxyAxios } from '@fastgpt/service/common/api/axios'; import { createProxyAxios } from '@fastgpt/service/common/api/axios';
import { PRIVATE_URL_TEXT } from '@fastgpt/service/common/system/utils';
type AxiosRequestConfig = { type AxiosRequestConfig = {
timeout?: number; timeout?: number;
...@@ -86,6 +87,69 @@ describe('axios.ts', () => { ...@@ -86,6 +87,69 @@ describe('axios.ts', () => {
expect(typeof instance.delete).toBe('function'); expect(typeof instance.delete).toBe('function');
expect(typeof instance.request).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 导出实例', () => { describe('axios 导出实例', () => {
......
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { isInternalAddress } from '@fastgpt/service/common/system/utils'; 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'; import dns from 'dns/promises';
describe('SSRF Protection - isInternalAddress', () => { describe('SSRF Protection - isInternalAddress', () => {
...@@ -17,8 +7,10 @@ describe('SSRF Protection - isInternalAddress', () => { ...@@ -17,8 +7,10 @@ describe('SSRF Protection - isInternalAddress', () => {
beforeEach(() => { beforeEach(() => {
process.env.CHECK_INTERNAL_IP = 'true'; process.env.CHECK_INTERNAL_IP = 'true';
// 清除所有 mock // 重建 DNS spy,避免真实 DNS 解析和用例之间的 mock 实现串味
vi.clearAllMocks(); vi.restoreAllMocks();
vi.spyOn(dns, 'resolve4').mockRejectedValue(new Error('No A records'));
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records'));
}); });
afterEach(() => { afterEach(() => {
......
...@@ -36,7 +36,7 @@ beforeEach(() => { ...@@ -36,7 +36,7 @@ beforeEach(() => {
}); });
describe('MCPClient', () => { 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', () => { describe('assertMCPUrlNotInternal', () => {
it('should reject localhost MCP endpoints', async () => { it('should reject localhost MCP endpoints', async () => {
......
Subproject commit 70380fe53914fff72e256be0e3b4eb649ae1ad90 Subproject commit 1a56dfc0d3c2fc1dc3e4a2f23ad1847926c804a8
...@@ -48,7 +48,7 @@ export const delDatasetById = (id: string) => DELETE(`/core/dataset/delete?id=${ ...@@ -48,7 +48,7 @@ export const delDatasetById = (id: string) => DELETE(`/core/dataset/delete?id=${
export const postDatasetSync = (data: PostDatasetSyncParams) => export const postDatasetSync = (data: PostDatasetSyncParams) =>
POST(`/proApi/core/dataset/datasetSync`, data, { POST(`/proApi/core/dataset/datasetSync`, data, {
timeout: 600000 timeout: 600000
}).catch(); });
export const postCreateDatasetFolder = (data: CreateDatasetFolderBody) => export const postCreateDatasetFolder = (data: CreateDatasetFolderBody) =>
POST(`/core/dataset/folder/create`, data); POST(`/core/dataset/folder/create`, data);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment