* fix: http tool

* fix: http tool

* fix: test

* fix: test

* fix: test

* fix: test
This commit is contained in:
Archer
2026-03-13 17:24:15 +08:00
committed by GitHub
parent df04515b1c
commit dbc443a770
15 changed files with 494 additions and 93 deletions
@@ -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. 实施修复方案
+4 -4
View File
@@ -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';
+12 -6
View File
@@ -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, {
+2
View File
@@ -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)
+2
View File
@@ -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
}
});
+5 -27
View File
@@ -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 =
+191
View File
@@ -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;
}
};
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
maxConcurrency: 1,
isolate: false,
env: {
CHECK_INTERNAL_IP: 'true',
SANDBOX_MAX_TIMEOUT: '5000',
SANDBOX_TOKEN: 'test'
}
+5 -24
View File
@@ -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'));
+62 -23
View File
@@ -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();
});
});