mirror of
https://github.com/labring/FastGPT.git
synced 2026-04-26 02:07:28 +08:00
Fix share (#6554)
* fix: http tool * fix: http tool * fix: test * fix: test * fix: test * fix: test
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
# 工作流调试弹窗表单输入内容清空问题分析
|
||||
|
||||
## 问题描述
|
||||
|
||||
**位置**: 工作流画布右侧的 ChatTest 调试弹窗(运行测试对话窗口)
|
||||
**前提**: 包含表单输入节点(用户可输入内容)
|
||||
**现象**: 用户填写内容后提交,工作流继续运行。关闭调试弹窗后再打开,历史记录中的表单内容被清空
|
||||
**预期**: 内容不应被清空
|
||||
|
||||
## 数据流分析
|
||||
|
||||
### 正常流程
|
||||
|
||||
1. **用户提交表单** → `AIResponseBox.tsx` 中的 `RenderUserFormInteractive` 组件
|
||||
2. **调用 handleFormSubmit** → 将表单数据 JSON 化并通过 `onSendPrompt` 发送
|
||||
3. **发送到后端** → `/api/core/chat/chatTest` 接收请求
|
||||
4. **工作流执行** → `dispatchWorkFlow` 处理表单输入节点
|
||||
5. **保存聊天记录** → 调用 `updateInteractiveChat` 更新数据库
|
||||
6. **更新 interactive** → `saveChat.ts` 中更新 `inputForm[].value`
|
||||
7. **关闭弹窗** → 调试状态保存
|
||||
8. **重新打开弹窗** → 从数据库读取聊天记录
|
||||
9. **渲染表单** → `RenderUserFormInteractive` 使用 `item.value ?? item.defaultValue`
|
||||
|
||||
### 关键代码位置
|
||||
|
||||
#### 1. 前端表单提交 (`AIResponseBox.tsx`)
|
||||
|
||||
```typescript
|
||||
// 第 231-237 行:计算 defaultValues
|
||||
const defaultValues = useMemo(() => {
|
||||
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item, index) => {
|
||||
// 使用 ?? 运算符,只有 undefined 或 null 时才使用 defaultValue
|
||||
acc[item.key] = item.value ?? item.defaultValue;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [interactive]);
|
||||
```
|
||||
|
||||
#### 2. 后端保存逻辑 (`saveChat.ts`)
|
||||
|
||||
```typescript
|
||||
// 第 495-525 行:更新 inputForm 值
|
||||
if (
|
||||
(finalInteractive.type === 'userInput' || finalInteractive.type === 'agentPlanAskUserForm') &&
|
||||
typeof parsedUserInteractiveVal === 'object'
|
||||
) {
|
||||
finalInteractive.params.inputForm = finalInteractive.params.inputForm.map((item) => {
|
||||
const itemValue = parsedUserInteractiveVal[item.key];
|
||||
if (itemValue === undefined) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
value: itemValue // ✅ 保存用户输入的值
|
||||
};
|
||||
});
|
||||
finalInteractive.params.submitted = true; // ✅ 标记为已提交
|
||||
}
|
||||
|
||||
// 第 533 行:将更新后的 interactive 赋值给最后一条消息
|
||||
chatItem.value[chatItem.value.length - 1].interactive = interactive;
|
||||
```
|
||||
|
||||
#### 3. API 调用 (`chatTest.ts`)
|
||||
|
||||
```typescript
|
||||
// 第 263-267 行:根据是否有 interactive 选择保存方式
|
||||
if (interactive) {
|
||||
await updateInteractiveChat({
|
||||
interactive,
|
||||
...params
|
||||
});
|
||||
} else {
|
||||
await pushChatRecords(params);
|
||||
}
|
||||
```
|
||||
|
||||
## 问题排查
|
||||
|
||||
需要验证以下几点:
|
||||
|
||||
1. **后端是否正确保存了 `inputForm[].value`?**
|
||||
- 检查数据库中的 `chat_items` 集合
|
||||
- 查看 `value` 字段中的 `interactive.params.inputForm` 是否包含用户提交的值
|
||||
|
||||
2. **前端是否正确读取了保存的值?**
|
||||
- 检查 `getChatRecords` API 返回的数据
|
||||
- 查看 `interactive.params.inputForm[].value` 是否存在
|
||||
|
||||
3. **是否有其他地方覆盖了 `interactive` 数据?**
|
||||
- 检查是否有缓存或状态管理覆盖了数据库的值
|
||||
|
||||
## 调试步骤
|
||||
|
||||
### 1. 检查数据库保存
|
||||
|
||||
在 `saveChat.ts` 的 `updateInteractiveChat` 函数中添加日志:
|
||||
|
||||
```typescript
|
||||
// 第 498 行之后
|
||||
finalInteractive.params.inputForm = finalInteractive.params.inputForm.map((item) => {
|
||||
const itemValue = parsedUserInteractiveVal[item.key];
|
||||
if (itemValue === undefined) return item;
|
||||
|
||||
console.log('Saving form value:', { key: item.key, value: itemValue }); // 添加日志
|
||||
|
||||
return {
|
||||
...item,
|
||||
value: itemValue
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 检查 API 返回数据
|
||||
|
||||
在 `AIResponseBox.tsx` 中添加日志:
|
||||
|
||||
```typescript
|
||||
// 第 231 行之后
|
||||
const defaultValues = useMemo(() => {
|
||||
console.log('Interactive data:', interactive); // 添加日志
|
||||
console.log('InputForm:', interactive.params.inputForm); // 添加日志
|
||||
|
||||
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item, index) => {
|
||||
console.log('Form item:', { key: item.key, value: item.value, defaultValue: item.defaultValue }); // 添加日志
|
||||
acc[item.key] = item.value ?? item.defaultValue;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [interactive]);
|
||||
```
|
||||
|
||||
### 3. 检查数据库记录
|
||||
|
||||
直接查询 MongoDB:
|
||||
|
||||
```javascript
|
||||
db.chat_items.find({
|
||||
chatId: "your_chat_id",
|
||||
obj: "AI"
|
||||
}).sort({ _id: -1 }).limit(1)
|
||||
```
|
||||
|
||||
查看返回的 `value` 字段中的 `interactive.params.inputForm` 是否包含 `value` 属性。
|
||||
|
||||
## 可能的原因
|
||||
|
||||
### 原因 1: 数据库未正确保存
|
||||
|
||||
如果 `updateInteractiveChat` 没有被正确调用,或者保存失败,数据库中就不会有用户提交的值。
|
||||
|
||||
**验证方法**: 检查数据库记录
|
||||
|
||||
### 原因 2: API 返回数据不完整
|
||||
|
||||
如果 `getChatRecords` API 没有返回完整的 `interactive` 数据,前端就无法显示用户提交的值。
|
||||
|
||||
**验证方法**: 检查 API 响应
|
||||
|
||||
### 原因 3: 前端状态管理问题
|
||||
|
||||
如果前端有缓存或状态管理覆盖了数据库的值,也会导致表单内容被清空。
|
||||
|
||||
**验证方法**: 检查 React 组件的 props 和 state
|
||||
|
||||
## 临时解决方案
|
||||
|
||||
如果问题是由于数据库未正确保存导致的,可以使用 `sessionStorage` 作为临时方案:
|
||||
|
||||
```typescript
|
||||
// 在 AIResponseBox.tsx 的 defaultValues 计算中
|
||||
const defaultValues = useMemo(() => {
|
||||
// 尝试从 sessionStorage 恢复数据
|
||||
let savedData: Record<string, any> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`interactiveForm_${chatItemDataId}`);
|
||||
if (saved) {
|
||||
savedData = JSON.parse(saved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse saved form data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return interactive.params.inputForm?.reduce((acc: Record<string, any>, item, index) => {
|
||||
// 优先使用 item.value,其次使用 sessionStorage,最后使用 defaultValue
|
||||
acc[item.key] = item.value ?? savedData[item.key] ?? item.defaultValue;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [interactive, chatItemDataId]);
|
||||
```
|
||||
|
||||
但这只是临时方案,根本问题还是需要确保数据库正确保存了用户提交的值。
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 添加日志验证数据流
|
||||
2. 检查数据库记录
|
||||
3. 根据调试结果确定具体原因
|
||||
4. 实施修复方案
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SERVICE_LOCAL_HOST } from './tools';
|
||||
import { isIP } from 'net';
|
||||
import * as dns from 'node:dns/promises';
|
||||
import { SERVICE_LOCAL_HOST } from './tools';
|
||||
|
||||
export const isInternalAddress = async (url: string): Promise<boolean> => {
|
||||
const isInternalIPv6 = (ip: string): boolean => {
|
||||
@@ -138,9 +138,8 @@ export const isInternalAddress = async (url: string): Promise<boolean> => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 默认启用内部 IP 检查(安全优先)
|
||||
// 只有显式设置 CHECK_INTERNAL_IP=false 时才禁用检查
|
||||
if (process.env.CHECK_INTERNAL_IP === 'false') {
|
||||
// 3. 只有显式设置 CHECK_INTERNAL_IP=true 时才启用私有 IP 检查
|
||||
if (process.env.CHECK_INTERNAL_IP !== 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -185,3 +184,4 @@ export const isInternalAddress = async (url: string): Promise<boolean> => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const PRIVATE_URL_TEXT = 'Request to private network not allowed';
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { RequireOnlyOne } from '@fastgpt/global/common/type/utils';
|
||||
import type { HttpToolConfigType } from '@fastgpt/global/core/app/tool/httpTool/type';
|
||||
import { contentTypeMap, ContentTypes } from '@fastgpt/global/core/workflow/constants';
|
||||
import { replaceEditorVariable } from '@fastgpt/global/core/workflow/runtime/utils';
|
||||
import { isInternalAddress } from '../../common/system/utils';
|
||||
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../common/system/utils';
|
||||
|
||||
export type RunHTTPToolParams = {
|
||||
baseUrl: string;
|
||||
@@ -138,15 +138,20 @@ export const runHTTPTool = async ({
|
||||
}: RunHTTPToolParams): Promise<RunHTTPToolResult> => {
|
||||
try {
|
||||
// Construct full base URL
|
||||
const fullBaseUrl =
|
||||
baseUrl.startsWith('http://') || baseUrl.startsWith('https://')
|
||||
const fullBaseUrl = !baseUrl
|
||||
? ''
|
||||
: baseUrl.startsWith('http://') || baseUrl.startsWith('https://')
|
||||
? baseUrl
|
||||
: `https://${baseUrl}`;
|
||||
|
||||
// SSRF Protection: Validate URL before making request
|
||||
const fullUrl = new URL(toolPath, fullBaseUrl).toString();
|
||||
// When baseUrl is empty, toolPath must be a complete URL
|
||||
const fullUrl = fullBaseUrl
|
||||
? new URL(toolPath, fullBaseUrl).toString()
|
||||
: new URL(toolPath).toString();
|
||||
|
||||
if (await isInternalAddress(fullUrl)) {
|
||||
return { errorMsg: 'Access to internal addresses is not allowed' };
|
||||
return { errorMsg: PRIVATE_URL_TEXT };
|
||||
}
|
||||
|
||||
const { headers, body, queryParams } = buildHttpRequest({
|
||||
@@ -170,7 +175,8 @@ export const runHTTPTool = async ({
|
||||
});
|
||||
|
||||
return { data };
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return { errorMsg: getErrText(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isInternalAddress } from '../../../../../../../common/system/utils';
|
||||
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../../../../common/system/utils';
|
||||
import axios from 'axios';
|
||||
import { serverRequestBaseUrl } from '../../../../../../../common/api/serverRequest';
|
||||
import { parseFileExtensionFromUrl } from '@fastgpt/global/common/string/tools';
|
||||
@@ -59,7 +59,7 @@ export const dispatchFileRead = async ({
|
||||
return {
|
||||
index,
|
||||
name: '',
|
||||
content: Promise.reject('Url is invalid')
|
||||
content: Promise.reject(PRIVATE_URL_TEXT)
|
||||
};
|
||||
}
|
||||
// Get file buffer data
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { StoreSecretValueType } from '@fastgpt/global/common/secret/type';
|
||||
import { getLogger, LogCategories } from '../../../../common/logger';
|
||||
import { SERVICE_LOCAL_HOST } from '../../../../common/system/tools';
|
||||
import { formatHttpError } from '../utils';
|
||||
import { isInternalAddress } from '../../../../common/system/utils';
|
||||
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils';
|
||||
import { serviceRequestMaxContentLength } from '../../../../common/system/constants';
|
||||
import { axios } from '../../../../common/api/axios';
|
||||
|
||||
@@ -496,7 +496,7 @@ async function fetchData({
|
||||
timeout: number;
|
||||
}) {
|
||||
if (await isInternalAddress(url)) {
|
||||
return Promise.reject('Url is invalid');
|
||||
return Promise.reject(PRIVATE_URL_TEXT);
|
||||
}
|
||||
|
||||
const { data: response } = await axios({
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
|
||||
import { type ChatItemType } from '@fastgpt/global/core/chat/type';
|
||||
import { addDays } from 'date-fns';
|
||||
import { getNodeErrResponse } from '../utils';
|
||||
import { isInternalAddress } from '../../../../common/system/utils';
|
||||
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils';
|
||||
import { replaceS3KeyToPreviewUrl } from '../../../dataset/utils';
|
||||
import { getFileS3Key } from '../../../../common/s3/utils';
|
||||
import { S3ChatSource } from '../../../../common/s3/sources/chat';
|
||||
@@ -188,7 +188,7 @@ export const getFileContentFromLinks = async ({
|
||||
|
||||
try {
|
||||
if (await isInternalAddress(url)) {
|
||||
return Promise.reject('Url is invalid');
|
||||
return Promise.reject(PRIVATE_URL_TEXT);
|
||||
}
|
||||
|
||||
// Get file buffer data
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextAPI } from '@/service/middleware/entry';
|
||||
import { type ApiRequestProps } from '@fastgpt/service/type/next';
|
||||
import { axios } from '@fastgpt/service/common/api/axios';
|
||||
import { authCert } from '@fastgpt/service/support/permission/auth/common';
|
||||
import { isInternalAddress } from '@fastgpt/service/common/system/utils';
|
||||
import { isInternalAddress, PRIVATE_URL_TEXT } from '@fastgpt/service/common/system/utils';
|
||||
import { type NextApiResponse } from 'next';
|
||||
|
||||
export type FetchWorkflowBody = {
|
||||
@@ -27,7 +27,7 @@ async function handler(
|
||||
return Promise.reject('Url is empty');
|
||||
}
|
||||
if (await isInternalAddress(url)) {
|
||||
return Promise.reject('Url is invalid');
|
||||
return Promise.reject(PRIVATE_URL_TEXT);
|
||||
}
|
||||
|
||||
const { data } = await axios.get(url, {
|
||||
|
||||
@@ -17,6 +17,8 @@ SANDBOX_MAX_MEMORY_MB=256
|
||||
SANDBOX_POOL_SIZE=20
|
||||
|
||||
# ===== Network Request Limits =====
|
||||
# Whether to check if the request is to a private network
|
||||
CHECK_INTERNAL_IP=false
|
||||
# Maximum number of HTTP requests per execution
|
||||
SANDBOX_REQUEST_MAX_COUNT=30
|
||||
# Timeout for each outbound HTTP request (ms)
|
||||
|
||||
@@ -35,6 +35,7 @@ const envSchema = z.object({
|
||||
SANDBOX_MAX_MEMORY_MB: int(256).pipe(z.number().min(32).max(4096)),
|
||||
|
||||
// ===== 网络请求限制 =====
|
||||
CHECK_INTERNAL_IP: z.coerce.boolean().default(false),
|
||||
SANDBOX_REQUEST_MAX_COUNT: int(30).pipe(z.number().min(1).max(1000)),
|
||||
SANDBOX_REQUEST_TIMEOUT: int(60000).pipe(z.number().min(1000).max(300000)),
|
||||
SANDBOX_REQUEST_MAX_RESPONSE_MB: int(10).pipe(z.number().min(1).max(100)),
|
||||
@@ -84,6 +85,7 @@ export const env = {
|
||||
poolSize: e.SANDBOX_POOL_SIZE,
|
||||
|
||||
// 网络请求限制
|
||||
checkInternalIp: e.CHECK_INTERNAL_IP,
|
||||
maxRequests: e.SANDBOX_REQUEST_MAX_COUNT,
|
||||
requestTimeoutMs: e.SANDBOX_REQUEST_TIMEOUT,
|
||||
maxResponseSize: e.SANDBOX_REQUEST_MAX_RESPONSE_MB,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { promisify } from 'util';
|
||||
import { platform } from 'os';
|
||||
import { config } from '../config';
|
||||
import type { ExecuteOptions, ExecuteResult } from '../types';
|
||||
import { env } from '../env';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -116,7 +117,8 @@ export abstract class BaseProcessPool {
|
||||
const proc = spawn('sh', ['-c', cmd], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin'
|
||||
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
|
||||
CHECK_INTERNAL_IP: process.env.CHECK_INTERNAL_IP
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import * as crypto from 'crypto';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as dns from 'dns';
|
||||
import * as net from 'net';
|
||||
import { isInternalAddress } from '../utils/network';
|
||||
const _OriginalFunction = Function;
|
||||
|
||||
// ===== 安全 shim =====
|
||||
@@ -143,29 +143,6 @@ function ipToLong(ip: string): number {
|
||||
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
||||
}
|
||||
|
||||
function isBlockedIP(rawIp: string): boolean {
|
||||
let ip = rawIp;
|
||||
if (!ip) return true;
|
||||
if (ip === '::1' || ip === '::') return true;
|
||||
if (ip.startsWith('::ffff:')) ip = ip.slice(7);
|
||||
if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true;
|
||||
if (!net.isIPv4(ip)) return false;
|
||||
const ipLong = ipToLong(ip);
|
||||
const cidrs: [string, number][] = [
|
||||
['10.0.0.0', 8],
|
||||
['172.16.0.0', 12],
|
||||
['192.168.0.0', 16],
|
||||
['169.254.0.0', 16],
|
||||
['127.0.0.0', 8],
|
||||
['0.0.0.0', 8]
|
||||
];
|
||||
for (const [base, bits] of cidrs) {
|
||||
const mask = (0xffffffff << (32 - bits)) >>> 0;
|
||||
if ((ipLong & mask) === (ipToLong(base) & mask)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function dnsResolve(hostname: string): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.lookup(hostname, { all: true }, (err, addresses) => {
|
||||
@@ -214,10 +191,11 @@ const SystemHelper = {
|
||||
if (!REQUEST_LIMITS.allowedProtocols.includes(parsed.protocol)) {
|
||||
throw new Error('Protocol not allowed');
|
||||
}
|
||||
const ips = await dnsResolve(parsed.hostname);
|
||||
for (const ip of ips) {
|
||||
if (isBlockedIP(ip)) throw new Error('Request to private network not allowed');
|
||||
// 先检查 URL 是否指向内部地址
|
||||
if (await isInternalAddress(url)) {
|
||||
throw new Error('Request to private network not allowed');
|
||||
}
|
||||
const ips = await dnsResolve(parsed.hostname);
|
||||
const method = (opts.method || 'GET').toUpperCase();
|
||||
const headers = opts.headers || {};
|
||||
const body =
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { isIP, isIPv6 } from 'net';
|
||||
import * as dns from 'dns/promises';
|
||||
|
||||
export const isInternalAddress = async (url: string): Promise<boolean> => {
|
||||
const SERVICE_LOCAL_PORT = `${process.env.PORT || 3000}`;
|
||||
const SERVICE_LOCAL_HOST =
|
||||
process.env.HOSTNAME && isIPv6(process.env.HOSTNAME)
|
||||
? `[${process.env.HOSTNAME}]:${SERVICE_LOCAL_PORT}`
|
||||
: `${process.env.HOSTNAME || 'localhost'}:${SERVICE_LOCAL_PORT}`;
|
||||
|
||||
const isInternalIPv6 = (ip: string): boolean => {
|
||||
// 移除 IPv6 地址中的方括号(如果有)
|
||||
const cleanIp = ip.replace(/^\[|\]$/g, '');
|
||||
|
||||
// 检查 IPv4-mapped IPv6 地址(格式:::ffff:xxxx:xxxx)
|
||||
// Node.js URL 解析器会将 IPv4 部分转换为十六进制
|
||||
const ipv4MappedPattern = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i;
|
||||
const ipv4MappedMatch = cleanIp.match(ipv4MappedPattern);
|
||||
|
||||
if (ipv4MappedMatch) {
|
||||
// 将十六进制转换回 IPv4 地址
|
||||
const hex1 = parseInt(ipv4MappedMatch[1], 16);
|
||||
const hex2 = parseInt(ipv4MappedMatch[2], 16);
|
||||
|
||||
// hex1 包含前两个字节,hex2 包含后两个字节
|
||||
const byte1 = (hex1 >> 8) & 0xff;
|
||||
const byte2 = hex1 & 0xff;
|
||||
const byte3 = (hex2 >> 8) & 0xff;
|
||||
const byte4 = hex2 & 0xff;
|
||||
|
||||
const ipv4 = `${byte1}.${byte2}.${byte3}.${byte4}`;
|
||||
return isInternalIPv4(ipv4);
|
||||
}
|
||||
|
||||
// IPv6 内部地址范围
|
||||
const internalIPv6Patterns = [
|
||||
/^::1$/, // Loopback
|
||||
/^::$/, // Unspecified
|
||||
/^fe80:/i, // Link-local
|
||||
/^fc00:/i, // Unique local address (ULA)
|
||||
/^fd00:/i, // Unique local address (ULA)
|
||||
/^::ffff:0:0/i, // IPv4-mapped IPv6
|
||||
/^::ffff:127\./i, // IPv4-mapped loopback
|
||||
/^::ffff:10\./i, // IPv4-mapped private (10.0.0.0/8)
|
||||
/^::ffff:172\.(1[6-9]|2[0-9]|3[0-1])\./i, // IPv4-mapped private (172.16.0.0/12)
|
||||
/^::ffff:192\.168\./i, // IPv4-mapped private (192.168.0.0/16)
|
||||
/^::ffff:169\.254\./i, // IPv4-mapped link-local
|
||||
/^::ffff:100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./i // IPv4-mapped shared address space
|
||||
];
|
||||
|
||||
return internalIPv6Patterns.some((pattern) => pattern.test(cleanIp));
|
||||
};
|
||||
const isInternalIPv4 = (ip: string): boolean => {
|
||||
// 验证是否为有效的 IPv4 格式
|
||||
const ipv4Pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const match = ip.match(ipv4Pattern);
|
||||
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 解析 IP 地址的各个部分
|
||||
const parts = [
|
||||
parseInt(match[1], 10),
|
||||
parseInt(match[2], 10),
|
||||
parseInt(match[3], 10),
|
||||
parseInt(match[4], 10)
|
||||
];
|
||||
|
||||
// 验证每个部分是否在有效范围内 (0-255)
|
||||
if (parts.some((part) => part < 0 || part > 255)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否为内部 IP 地址范围
|
||||
return (
|
||||
parts[0] === 0 || // 0.0.0.0/8 - Current network
|
||||
parts[0] === 10 || // 10.0.0.0/8 - Private network
|
||||
parts[0] === 127 || // 127.0.0.0/8 - Loopback
|
||||
(parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16 - Link-local
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 - Private network
|
||||
(parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16 - Private network
|
||||
(parts[0] >= 224 && parts[0] <= 239) || // 224.0.0.0/4 - Multicast
|
||||
(parts[0] >= 240 && parts[0] <= 255) || // 240.0.0.0/4 - Reserved
|
||||
(parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || // 100.64.0.0/10 - Shared address space
|
||||
(parts[0] === 192 && parts[1] === 0 && parts[2] === 0) || // 192.0.0.0/24 - IETF Protocol Assignments
|
||||
(parts[0] === 192 && parts[1] === 0 && parts[2] === 2) || // 192.0.2.0/24 - Documentation (TEST-NET-1)
|
||||
(parts[0] === 198 && parts[1] === 18) || // 198.18.0.0/15 - Benchmarking
|
||||
(parts[0] === 198 && parts[1] === 19) || // 198.18.0.0/15 - Benchmarking
|
||||
(parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || // 198.51.100.0/24 - Documentation (TEST-NET-2)
|
||||
(parts[0] === 203 && parts[1] === 0 && parts[2] === 113) // 203.0.113.0/24 - Documentation (TEST-NET-3)
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
// 移除 IPv6 地址的方括号(如果有)
|
||||
const hostname = parsedUrl.hostname.replace(/^\[|\]$/g, '');
|
||||
const fullUrl = parsedUrl.toString();
|
||||
|
||||
// 1. 检查 localhost 和常见的本地域名变体
|
||||
const localhostVariants = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
|
||||
const localHostname = SERVICE_LOCAL_HOST.split(':')[0];
|
||||
|
||||
if (localhostVariants.includes(hostname) || hostname === localHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. 检查云服务商元数据端点(始终阻止,无论 CHECK_INTERNAL_IP 设置如何)
|
||||
const metadataEndpoints = [
|
||||
// AWS
|
||||
'http://169.254.169.254/',
|
||||
'http://[fd00:ec2::254]/',
|
||||
|
||||
// Azure
|
||||
'http://169.254.169.254/',
|
||||
|
||||
// GCP
|
||||
'http://metadata.google.internal/',
|
||||
'http://metadata/',
|
||||
|
||||
// Alibaba Cloud
|
||||
'http://100.100.100.200/',
|
||||
|
||||
// Tencent Cloud
|
||||
'http://metadata.tencentyun.com/',
|
||||
|
||||
// Huawei Cloud
|
||||
'http://169.254.169.254/',
|
||||
|
||||
// Oracle Cloud
|
||||
'http://169.254.169.254/',
|
||||
|
||||
// DigitalOcean
|
||||
'http://169.254.169.254/',
|
||||
|
||||
// Kubernetes
|
||||
'http://kubernetes.default.svc/',
|
||||
'https://kubernetes.default.svc/'
|
||||
];
|
||||
|
||||
if (metadataEndpoints.some((endpoint) => fullUrl.startsWith(endpoint))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. 默认不检查
|
||||
if (process.env.CHECK_INTERNAL_IP !== 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. 使用 Node.js 的 isIP 函数检测 IP 版本
|
||||
const ipVersion = isIP(hostname);
|
||||
|
||||
if (ipVersion === 4) {
|
||||
// IPv4 地址检查
|
||||
return isInternalIPv4(hostname);
|
||||
} else if (ipVersion === 6) {
|
||||
// IPv6 地址检查
|
||||
return isInternalIPv6(hostname);
|
||||
} else {
|
||||
// 不是 IP 地址,是域名 - 需要解析
|
||||
try {
|
||||
// 解析所有 A 和 AAAA 记录
|
||||
const [ipv4Addresses, ipv6Addresses] = await Promise.allSettled([
|
||||
dns.resolve4(hostname),
|
||||
dns.resolve6(hostname)
|
||||
]);
|
||||
|
||||
// 检查所有解析的 IP 是否为内部地址
|
||||
const allIPs = [
|
||||
...(ipv4Addresses.status === 'fulfilled' ? ipv4Addresses.value : []),
|
||||
...(ipv6Addresses.status === 'fulfilled' ? ipv6Addresses.value : [])
|
||||
];
|
||||
|
||||
// 如果任何一个解析的 IP 是内部地址,则拒绝
|
||||
for (const ip of allIPs) {
|
||||
if (isInternalIPv4(ip) || isInternalIPv6(ip)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// URL 解析失败 - 宽松策略:允许访问
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,7 @@ export default defineConfig({
|
||||
maxConcurrency: 1,
|
||||
isolate: false,
|
||||
env: {
|
||||
CHECK_INTERNAL_IP: 'true',
|
||||
SANDBOX_MAX_TIMEOUT: '5000',
|
||||
SANDBOX_TOKEN: 'test'
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
const originalEnv = process.env.CHECK_INTERNAL_IP;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
// 清除所有 mock
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -93,16 +94,16 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
});
|
||||
|
||||
test('应该阻止私有 IP 地址(默认启用安全检查)', async () => {
|
||||
expect(await isInternalAddress('http://10.0.0.1/')).toBe(true);
|
||||
expect(await isInternalAddress('http://172.16.0.1/')).toBe(true);
|
||||
expect(await isInternalAddress('http://192.168.1.1/')).toBe(true);
|
||||
expect(await isInternalAddress('http://10.0.0.1/')).toBe(false);
|
||||
expect(await isInternalAddress('http://172.16.0.1/')).toBe(false);
|
||||
expect(await isInternalAddress('http://192.168.1.1/')).toBe(false);
|
||||
});
|
||||
|
||||
test('应该阻止解析到私有 IP 的域名', async () => {
|
||||
vi.mocked(dns.resolve4).mockResolvedValue(['10.0.0.1']);
|
||||
vi.mocked(dns.resolve6).mockRejectedValue(new Error('No AAAA records'));
|
||||
|
||||
expect(await isInternalAddress('http://internal.example.com/')).toBe(true);
|
||||
expect(await isInternalAddress('http://internal.example.com/')).toBe(false);
|
||||
});
|
||||
|
||||
test('应该允许解析到公共 IP 的域名', async () => {
|
||||
@@ -149,10 +150,6 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
});
|
||||
|
||||
describe('CHECK_INTERNAL_IP=true 时(启用完整检查)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
});
|
||||
|
||||
test('应该允许公共 IP 地址', async () => {
|
||||
expect(await isInternalAddress('http://8.8.8.8/')).toBe(false);
|
||||
expect(await isInternalAddress('http://1.1.1.1/')).toBe(false);
|
||||
@@ -227,10 +224,6 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
});
|
||||
|
||||
describe('DNS 解析功能测试(CHECK_INTERNAL_IP=true)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
});
|
||||
|
||||
test('应该阻止解析到私有 IPv4 的域名', async () => {
|
||||
vi.mocked(dns.resolve4).mockResolvedValue(['10.0.0.1']);
|
||||
vi.mocked(dns.resolve6).mockRejectedValue(new Error('No AAAA records'));
|
||||
@@ -325,10 +318,6 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
});
|
||||
|
||||
describe('边界情况和安全测试', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
});
|
||||
|
||||
test('应该正确处理带端口的 URL', async () => {
|
||||
expect(await isInternalAddress('http://10.0.0.1:8080/')).toBe(true);
|
||||
expect(await isInternalAddress('http://8.8.8.8:8080/')).toBe(false);
|
||||
@@ -379,10 +368,6 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
});
|
||||
|
||||
describe('已知绕过尝试(应该被阻止)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
});
|
||||
|
||||
test('应该阻止 localhost 变体', async () => {
|
||||
expect(await isInternalAddress('http://localhost/')).toBe(true);
|
||||
expect(await isInternalAddress('http://127.0.0.1/')).toBe(true);
|
||||
@@ -444,10 +429,6 @@ describe('SSRF Protection - isInternalAddress', () => {
|
||||
});
|
||||
|
||||
describe('混合场景测试', () => {
|
||||
beforeEach(() => {
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
});
|
||||
|
||||
test('应该正确处理同时有公共和私有 IP 的域名', async () => {
|
||||
vi.mocked(dns.resolve4).mockResolvedValue(['8.8.8.8', '10.0.0.1']);
|
||||
vi.mocked(dns.resolve6).mockRejectedValue(new Error('No AAAA records'));
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { runHTTPTool } from '@fastgpt/service/core/app/http';
|
||||
import { PRIVATE_URL_TEXT } from '@fastgpt/service/common/system/utils';
|
||||
|
||||
describe('SSRF Vulnerability Fix Tests', () => {
|
||||
const originalEnv = process.env.CHECK_INTERNAL_IP;
|
||||
|
||||
beforeEach(() => {
|
||||
// 确保测试环境启用内部 IP 检查
|
||||
delete process.env.CHECK_INTERNAL_IP;
|
||||
process.env.CHECK_INTERNAL_IP = 'true';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -27,7 +28,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -53,7 +54,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -65,7 +66,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -79,7 +80,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -91,7 +92,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -103,7 +104,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -117,7 +118,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -129,7 +130,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -141,7 +142,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -155,7 +156,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -167,7 +168,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -179,7 +180,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -193,7 +194,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -205,7 +206,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -219,7 +220,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -231,7 +232,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -243,7 +244,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -260,7 +261,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
});
|
||||
|
||||
it('should always block localhost even when CHECK_INTERNAL_IP=false', async () => {
|
||||
@@ -274,7 +275,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
});
|
||||
|
||||
it('should block internal addresses by default (no env var)', async () => {
|
||||
@@ -287,7 +288,7 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
});
|
||||
|
||||
it('should block internal addresses when CHECK_INTERNAL_IP=true', async () => {
|
||||
@@ -300,7 +301,45 @@ describe('SSRF Vulnerability Fix Tests', () => {
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe('Access to internal addresses is not allowed');
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty BaseUrl with Complete URL in toolPath', () => {
|
||||
it('should block internal address when baseUrl is empty and toolPath is complete URL', async () => {
|
||||
const result = await runHTTPTool({
|
||||
baseUrl: '',
|
||||
toolPath: 'http://localhost:8080/api/test',
|
||||
method: 'GET',
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should block AWS metadata when baseUrl is empty', async () => {
|
||||
const result = await runHTTPTool({
|
||||
baseUrl: '',
|
||||
toolPath: 'http://169.254.169.254/latest/meta-data/',
|
||||
method: 'GET',
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should block private IP when baseUrl is empty', async () => {
|
||||
const result = await runHTTPTool({
|
||||
baseUrl: '',
|
||||
toolPath: 'http://192.168.1.1/admin',
|
||||
method: 'GET',
|
||||
params: {}
|
||||
});
|
||||
|
||||
expect(result.errorMsg).toBe(PRIVATE_URL_TEXT);
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user