diff --git a/.claude/design/workflow-debug-form-input-bug-analysis.md b/.claude/design/workflow-debug-form-input-bug-analysis.md new file mode 100644 index 0000000000..656d9be65a --- /dev/null +++ b/.claude/design/workflow-debug-form-input-bug-analysis.md @@ -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, 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, 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 = {}; + 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, item, index) => { + // 优先使用 item.value,其次使用 sessionStorage,最后使用 defaultValue + acc[item.key] = item.value ?? savedData[item.key] ?? item.defaultValue; + return acc; + }, {}); +}, [interactive, chatItemDataId]); +``` + +但这只是临时方案,根本问题还是需要确保数据库正确保存了用户提交的值。 + +## 下一步 + +1. 添加日志验证数据流 +2. 检查数据库记录 +3. 根据调试结果确定具体原因 +4. 实施修复方案 diff --git a/packages/service/common/system/utils.ts b/packages/service/common/system/utils.ts index c5453c54b7..c10a0b1898 100644 --- a/packages/service/common/system/utils.ts +++ b/packages/service/common/system/utils.ts @@ -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 => { const isInternalIPv6 = (ip: string): boolean => { @@ -138,9 +138,8 @@ export const isInternalAddress = async (url: string): Promise => { 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 => { return false; } }; +export const PRIVATE_URL_TEXT = 'Request to private network not allowed'; diff --git a/packages/service/core/app/http.ts b/packages/service/core/app/http.ts index 1b3648386b..fba01a13c1 100644 --- a/packages/service/core/app/http.ts +++ b/packages/service/core/app/http.ts @@ -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 => { 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) }; } }; diff --git a/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts b/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts index fe0649538c..06e6c8cdde 100644 --- a/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts +++ b/packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts @@ -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 diff --git a/packages/service/core/workflow/dispatch/tools/http468.ts b/packages/service/core/workflow/dispatch/tools/http468.ts index 0a9b90f715..7884842959 100644 --- a/packages/service/core/workflow/dispatch/tools/http468.ts +++ b/packages/service/core/workflow/dispatch/tools/http468.ts @@ -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({ diff --git a/packages/service/core/workflow/dispatch/tools/readFiles.ts b/packages/service/core/workflow/dispatch/tools/readFiles.ts index 0a6960131b..6f9f0710cc 100644 --- a/packages/service/core/workflow/dispatch/tools/readFiles.ts +++ b/packages/service/core/workflow/dispatch/tools/readFiles.ts @@ -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 diff --git a/projects/app/src/pages/api/support/marketing/fetchWorkflow.ts b/projects/app/src/pages/api/support/marketing/fetchWorkflow.ts index fd71414121..bd9e4471e3 100644 --- a/projects/app/src/pages/api/support/marketing/fetchWorkflow.ts +++ b/projects/app/src/pages/api/support/marketing/fetchWorkflow.ts @@ -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, { diff --git a/projects/sandbox/.env.template b/projects/sandbox/.env.template index 03eefabd01..9e4feea4c0 100644 --- a/projects/sandbox/.env.template +++ b/projects/sandbox/.env.template @@ -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) diff --git a/projects/sandbox/src/env.ts b/projects/sandbox/src/env.ts index 4dc75853e1..aa509bd2f1 100644 --- a/projects/sandbox/src/env.ts +++ b/projects/sandbox/src/env.ts @@ -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, diff --git a/projects/sandbox/src/pool/base-process-pool.ts b/projects/sandbox/src/pool/base-process-pool.ts index 037ad9106e..41450e3c02 100644 --- a/projects/sandbox/src/pool/base-process-pool.ts +++ b/projects/sandbox/src/pool/base-process-pool.ts @@ -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 } }); diff --git a/projects/sandbox/src/pool/worker.ts b/projects/sandbox/src/pool/worker.ts index 1b0a9e47de..111331534c 100644 --- a/projects/sandbox/src/pool/worker.ts +++ b/projects/sandbox/src/pool/worker.ts @@ -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 { 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 = diff --git a/projects/sandbox/src/utils/network.ts b/projects/sandbox/src/utils/network.ts new file mode 100644 index 0000000000..6d20fc943a --- /dev/null +++ b/projects/sandbox/src/utils/network.ts @@ -0,0 +1,191 @@ +import { isIP, isIPv6 } from 'net'; +import * as dns from 'dns/promises'; + +export const isInternalAddress = async (url: string): Promise => { + 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; + } +}; diff --git a/projects/sandbox/vitest.config.ts b/projects/sandbox/vitest.config.ts index 8b19fc1c19..332e4554dc 100644 --- a/projects/sandbox/vitest.config.ts +++ b/projects/sandbox/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ maxConcurrency: 1, isolate: false, env: { + CHECK_INTERNAL_IP: 'true', SANDBOX_MAX_TIMEOUT: '5000', SANDBOX_TOKEN: 'test' } diff --git a/test/cases/service/common/system/utils.test.ts b/test/cases/service/common/system/utils.test.ts index 7e2b502e7c..3bf78a13fd 100644 --- a/test/cases/service/common/system/utils.test.ts +++ b/test/cases/service/common/system/utils.test.ts @@ -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')); diff --git a/test/cases/service/core/app/http.test.ts b/test/cases/service/core/app/http.test.ts index d1020d3dbe..88e78f1af6 100644 --- a/test/cases/service/core/app/http.test.ts +++ b/test/cases/service/core/app/http.test.ts @@ -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(); }); });