Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
赵月辉
/
fastgpt-migrated
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
1fd5eed8
authored
Apr 29, 2026
by
Archer
Committed by
GitHub
Apr 29, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
perf: ssrf check (#6852)
parent
225cb7e6
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
387 additions
and
280 deletions
+387
-280
.claude/issue/ssrf-vulnerability-fix.md
+0
-216
packages/service/common/api/axios.ts
+31
-0
packages/service/common/api/plusRequest.ts
+17
-2
packages/service/common/api/serverRequest.ts
+9
-0
packages/service/common/security/network.ts
+55
-0
packages/service/common/system/utils.ts
+27
-0
packages/service/core/ai/rerank/index.ts
+18
-15
packages/service/core/ai/sandbox/toolCall/index.ts
+2
-4
packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts
+2
-5
packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts
+10
-4
packages/service/core/workflow/utils/file.ts
+2
-5
packages/service/test/common/api/axios.test.ts
+29
-0
packages/service/test/common/security/network.test.ts
+121
-0
packages/service/test/core/ai/rerank/index.test.ts
+26
-22
packages/service/test/core/workflow/utils/file.test.ts
+26
-3
projects/app/src/pages/api/aiproxy/[...path].ts
+3
-1
projects/app/src/pages/api/lafApi/[...path].ts
+3
-1
projects/app/src/pages/api/marketplace/[...path].ts
+3
-1
projects/app/src/pages/api/proApi/[...path].ts
+3
-1
No files found.
.claude/issue/ssrf-vulnerability-fix.md
deleted
100644 → 0
View file @
225cb7e6
# SSRF 漏洞修复设计文档
## 漏洞概述
**漏洞编号**
: GHSA-6g6x-8hq5-9cw4
**漏洞类型**
: Server-Side Request Forgery (SSRF) - CWE-918
**严重程度**
: High
**影响版本**
: <= 4.8.22
## 漏洞详情
### 1. 主要问题
FastGPT 的 HTTP Tool 连接器在处理用户控制的 URL 时缺乏 SSRF 保护:
**受影响文件**
:
-
`packages/service/core/app/http.ts`
(lines 127-166) -
`runHTTPTool()`
函数
-
`projects/app/src/pages/api/core/app/httpTools/runTool.ts`
- API 端点
**问题代码**
:
```
typescript
export
const
runHTTPTool
=
async
({
baseUrl
,
toolPath
,
method
,
...
})
=>
{
const
{
data
}
=
await
axios
({
method
:
method
.
toUpperCase
(),
baseURL
:
baseUrl
.
startsWith
(
'http'
)
?
baseUrl
:
`https://
${
baseUrl
}
`
,
url
:
toolPath
,
// 没有任何 IP 验证!
});
};
```
### 2. 次要问题
`isInternalAddress()`
函数默认被禁用:
**文件**
:
`packages/service/common/system/utils.ts`
(line 142)
```
typescript
if
(
process
.
env
.
CHECK_INTERNAL_IP
!==
'true'
)
{
return
false
;
// 默认允许内部地址!
}
```
这意味着 http468 工作流节点和 readFiles 也缺乏 SSRF 保护,除非显式设置
`CHECK_INTERNAL_IP=true`
。
## 攻击场景
认证用户可以使用 HTTP Tool 进行以下攻击:
1.
**AWS 凭证窃取**
:
-
`baseUrl: http://169.254.169.254`
-
`toolPath: /latest/meta-data/iam/security-credentials/`
2.
**Kubernetes 密钥泄露**
:
-
`baseUrl: http://kubernetes.default.svc`
-
`toolPath: /api/v1/namespaces/default/secrets/`
3.
**内部网络扫描和服务利用**
## 修复方案
### 方案 1: 在 runHTTPTool 中添加 SSRF 保护(推荐)
**修改文件**
:
`packages/service/core/app/http.ts`
在
`runHTTPTool`
函数中,在发起请求前添加 URL 验证:
```
typescript
export
const
runHTTPTool
=
async
({
baseUrl
,
toolPath
,
method
=
'POST'
,
params
,
headerSecret
,
customHeaders
,
staticParams
,
staticHeaders
,
staticBody
}:
RunHTTPToolParams
):
Promise
<
RunHTTPToolResult
>
=>
{
try
{
// 构建完整 URL
const
fullBaseUrl
=
baseUrl
.
startsWith
(
'http://'
)
||
baseUrl
.
startsWith
(
'https://'
)
?
baseUrl
:
`https://
${
baseUrl
}
`
;
// SSRF 保护:验证 URL 是否指向内部地址
const
fullUrl
=
new
URL
(
toolPath
,
fullBaseUrl
).
toString
();
if
(
await
isInternalAddress
(
fullUrl
))
{
return
{
errorMsg
:
'Access to internal addresses is not allowed'
};
}
const
{
headers
,
body
,
queryParams
}
=
buildHttpRequest
({
method
,
params
,
headerSecret
,
customHeaders
,
staticParams
,
staticHeaders
,
staticBody
});
const
{
data
}
=
await
axios
({
method
:
method
.
toUpperCase
(),
baseURL
:
fullBaseUrl
,
url
:
toolPath
,
headers
,
data
:
body
,
params
:
queryParams
,
timeout
:
300000
});
return
{
data
};
}
catch
(
error
:
any
)
{
return
{
errorMsg
:
getErrText
(
error
)
};
}
};
```
### 方案 2: 修改 CHECK_INTERNAL_IP 默认值
**修改文件**
:
`packages/service/common/system/utils.ts`
将默认行为从"允许"改为"拒绝":
```
typescript
// 3. 如果未启用内部 IP 检查,则默认拒绝(安全优先)
if
(
process
.
env
.
CHECK_INTERNAL_IP
===
'false'
)
{
return
false
;
// 显式禁用检查时才允许
}
// 默认启用内部 IP 检查
```
**注意**
: 这个改动可能影响向后兼容性,需要在文档中说明。
### 方案 3: 添加 DNS Rebinding 保护(可选增强)
在
`isInternalAddress`
函数中,可以添加 DNS rebinding 保护:
1.
解析域名获取 IP
2.
验证 IP 是否为内部地址
3.
在实际请求时,固定使用已验证的 IP(而不是重新解析)
这需要修改 axios 请求的方式,使用已解析的 IP 而不是域名。
## 实施步骤
### 第一阶段:核心修复(必须)
1.
✅ 在
`runHTTPTool`
中添加
`isInternalAddress`
验证
2.
✅ 修改
`CHECK_INTERNAL_IP`
默认行为为启用
3.
✅ 添加单元测试验证修复
### 第二阶段:文档更新(必须)
1.
更新部署文档,说明
`CHECK_INTERNAL_IP`
环境变量的变化
2.
添加安全最佳实践文档
3.
更新 CHANGELOG
### 第三阶段:增强保护(可选)
1.
实现 DNS rebinding 保护
2.
添加请求日志和监控
3.
实现 URL 白名单机制
## 测试计划
### 单元测试
创建测试文件:
`test/cases/service/core/app/http.test.ts`
测试用例:
1.
✅ 测试拒绝 AWS 元数据端点 (169.254.169.254)
2.
✅ 测试拒绝 Kubernetes 服务 (kubernetes.default.svc)
3.
✅ 测试拒绝私有 IP 范围 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
4.
✅ 测试拒绝 localhost 和 127.0.0.1
5.
✅ 测试允许合法的外部 URL
6.
✅ 测试 DNS rebinding 场景(域名解析到内部 IP)
### 集成测试
1.
测试 HTTP Tool 在工作流中的行为
2.
测试 API 端点
`/api/core/app/httpTools/runTool`
3.
验证错误消息的正确性
## 向后兼容性
### 破坏性变更
1.
**CHECK_INTERNAL_IP 默认值变更**
:
-
旧行为: 默认允许内部地址访问
-
新行为: 默认拒绝内部地址访问
2.
**影响范围**
:
-
依赖访问内部服务的工作流将失败
-
需要显式设置
`CHECK_INTERNAL_IP=false`
来恢复旧行为(不推荐)
### 迁移指南
对于需要访问内部服务的合法用例:
1.
**推荐方案**
: 使用代理服务或 API 网关
2.
**临时方案**
: 设置
`CHECK_INTERNAL_IP=false`
(不安全,仅用于开发环境)
## 安全建议
1.
**生产环境**
: 始终保持
`CHECK_INTERNAL_IP=true`
(默认)
2.
**网络隔离**
: 在网络层面限制 FastGPT 服务器的出站访问
3.
**监控**
: 记录所有 HTTP Tool 请求,监控异常模式
4.
**最小权限**
: 限制 FastGPT 服务账号的权限
## 参考资料
-
[
CWE-918: Server-Side Request Forgery (SSRF)
](
https://cwe.mitre.org/data/definitions/918.html
)
-
[
OWASP SSRF Prevention Cheat Sheet
](
https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
)
-
GitHub Security Advisory: GHSA-6g6x-8hq5-9cw4
packages/service/common/api/axios.ts
View file @
1fd5eed8
...
@@ -2,6 +2,8 @@ import _, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
...
@@ -2,6 +2,8 @@ import _, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
import
{
ProxyAgent
}
from
'proxy-agent'
;
import
{
ProxyAgent
}
from
'proxy-agent'
;
import
{
isDevEnv
}
from
'@fastgpt/global/common/system/constants'
;
import
{
isDevEnv
}
from
'@fastgpt/global/common/system/constants'
;
import
{
isInternalAddress
,
PRIVATE_URL_TEXT
}
from
'../system/utils'
;
import
{
isInternalAddress
,
PRIVATE_URL_TEXT
}
from
'../system/utils'
;
import
{
isAbsoluteUrl
}
from
'../security/network'
;
import
{
SERVICE_LOCAL_HOST
}
from
'../system/tools'
;
const
addSSRFInterceptor
=
(
instance
:
AxiosInstance
)
=>
{
const
addSSRFInterceptor
=
(
instance
:
AxiosInstance
)
=>
{
instance
.
interceptors
.
request
.
use
(
async
(
config
)
=>
{
instance
.
interceptors
.
request
.
use
(
async
(
config
)
=>
{
...
@@ -41,3 +43,32 @@ export function createProxyAxios(config?: AxiosRequestConfig, ssrfCheck = true)
...
@@ -41,3 +43,32 @@ export function createProxyAxios(config?: AxiosRequestConfig, ssrfCheck = true)
/** @see https://github.com/axios/axios/issues/4531 */
/** @see https://github.com/axios/axios/issues/4531 */
export
const
axios
=
createProxyAxios
();
export
const
axios
=
createProxyAxios
();
/**
* 内部相对路径请求专用的 axios 实例:
* - baseURL 固定为本机 NextJS API
* - 不带 SSRF 拦截器(本机调用必然解析到 localhost,装拦截会把所有合法请求拦死)
* - 不复用 safe axios 的 ProxyAgent,保证内部回环不会被外部代理转走
*
* 仅在 url 是相对路径时使用;绝对 URL 必须走 safe `axios`。
*/
const
internalAxios
:
AxiosInstance
=
_
.
create
({
baseURL
:
`http://
${
SERVICE_LOCAL_HOST
}
`
});
/**
* 根据 URL 类型自动选择合适的 axios 实例,避免每个调用点重复
* `isAbsoluteUrl ? safe : raw` 三元。
*
* - 绝对 URL(`http(s)://...` 或 `//...`)→ safe `axios`(SSRF 拦截,拒绝内网/metadata)
* - 相对路径(`/api/...` 等)→ `internalAxios`(本机 baseURL,可信内部 API)
*
* 用法:
* ```ts
* const client = pickOutboundAxios(url);
* const res = await client.get(url, { responseType: 'arraybuffer' });
* ```
*/
export
const
pickOutboundAxios
=
(
url
:
string
):
AxiosInstance
=>
{
return
isAbsoluteUrl
(
url
)
?
axios
:
internalAxios
;
};
packages/service/common/api/plusRequest.ts
View file @
1fd5eed8
...
@@ -8,6 +8,7 @@ import { FastGPTProUrl } from '../system/constants';
...
@@ -8,6 +8,7 @@ import { FastGPTProUrl } from '../system/constants';
import
{
UserError
}
from
'@fastgpt/global/common/error/utils'
;
import
{
UserError
}
from
'@fastgpt/global/common/error/utils'
;
import
{
createProxyAxios
}
from
'./axios'
;
import
{
createProxyAxios
}
from
'./axios'
;
import
{
getLogger
,
LogCategories
}
from
'../logger'
;
import
{
getLogger
,
LogCategories
}
from
'../logger'
;
import
{
assertRelativePath
}
from
'../security/network'
;
const
logger
=
getLogger
(
LogCategories
.
HTTP
.
ERROR
);
const
logger
=
getLogger
(
LogCategories
.
HTTP
.
ERROR
);
...
@@ -92,6 +93,14 @@ export function request(url: string, data: any, config: ConfigType, method: Meth
...
@@ -92,6 +93,14 @@ export function request(url: string, data: any, config: ConfigType, method: Meth
return
Promise
.
reject
(
new
UserError
(
'The request was denied...'
));
return
Promise
.
reject
(
new
UserError
(
'The request was denied...'
));
}
}
// plusRequest 仅用于访问商业版 Pro 服务,会自动携带 rootkey,SSRF 拦截已被显式关闭。
// 强制要求相对路径,防止调用方传入绝对 URL 覆盖 baseURL 形成带高权限头的 SSRF。
try
{
assertRelativePath
(
url
,
'plusRequest'
);
}
catch
(
err
)
{
return
Promise
.
reject
(
err
);
}
/* 去空 */
/* 去空 */
for
(
const
key
in
data
)
{
for
(
const
key
in
data
)
{
if
(
data
[
key
]
===
null
||
data
[
key
]
===
undefined
)
{
if
(
data
[
key
]
===
null
||
data
[
key
]
===
undefined
)
{
...
@@ -135,8 +144,14 @@ export function DELETE<T = undefined>(url: string, data = {}, config: ConfigType
...
@@ -135,8 +144,14 @@ export function DELETE<T = undefined>(url: string, data = {}, config: ConfigType
return
request
(
url
,
data
,
config
,
'DELETE'
);
return
request
(
url
,
data
,
config
,
'DELETE'
);
}
}
export
const
plusRequest
=
(
config
:
AxiosRequestConfig
)
=>
export
const
plusRequest
=
(
config
:
AxiosRequestConfig
)
=>
{
instance
.
request
({
try
{
assertRelativePath
(
config
.
url
,
'plusRequest'
);
}
catch
(
err
)
{
return
Promise
.
reject
(
err
);
}
return
instance
.
request
({
...
config
,
...
config
,
baseURL
:
FastGPTProUrl
baseURL
:
FastGPTProUrl
});
});
};
packages/service/common/api/serverRequest.ts
View file @
1fd5eed8
...
@@ -2,6 +2,7 @@ import { SERVICE_LOCAL_HOST } from '../system/tools';
...
@@ -2,6 +2,7 @@ import { SERVICE_LOCAL_HOST } from '../system/tools';
import
{
type
Method
,
type
InternalAxiosRequestConfig
,
type
AxiosResponse
}
from
'axios'
;
import
{
type
Method
,
type
InternalAxiosRequestConfig
,
type
AxiosResponse
}
from
'axios'
;
import
{
createProxyAxios
}
from
'./axios'
;
import
{
createProxyAxios
}
from
'./axios'
;
import
{
getLogger
,
LogCategories
}
from
'../logger'
;
import
{
getLogger
,
LogCategories
}
from
'../logger'
;
import
{
assertRelativePath
}
from
'../security/network'
;
const
logger
=
getLogger
(
LogCategories
.
HTTP
.
ERROR
);
const
logger
=
getLogger
(
LogCategories
.
HTTP
.
ERROR
);
...
@@ -78,6 +79,14 @@ instance.interceptors.request.use(requestStart, (err) => Promise.reject(err));
...
@@ -78,6 +79,14 @@ instance.interceptors.request.use(requestStart, (err) => Promise.reject(err));
instance
.
interceptors
.
response
.
use
(
responseSuccess
,
(
err
)
=>
Promise
.
reject
(
err
));
instance
.
interceptors
.
response
.
use
(
responseSuccess
,
(
err
)
=>
Promise
.
reject
(
err
));
export
function
request
(
url
:
string
,
data
:
any
,
config
:
ConfigType
,
method
:
Method
):
any
{
export
function
request
(
url
:
string
,
data
:
any
,
config
:
ConfigType
,
method
:
Method
):
any
{
// serverRequest 仅用于访问本机 NextJS API,SSRF 拦截已被显式关闭。
// 强制要求相对路径,防止调用方传入绝对 URL 覆盖 baseURL 形成 SSRF。
try
{
assertRelativePath
(
url
,
'serverRequest'
);
}
catch
(
err
)
{
return
Promise
.
reject
(
err
);
}
/* 去空 */
/* 去空 */
for
(
const
key
in
data
)
{
for
(
const
key
in
data
)
{
if
(
data
[
key
]
===
null
||
data
[
key
]
===
undefined
)
{
if
(
data
[
key
]
===
null
||
data
[
key
]
===
undefined
)
{
...
...
packages/service/common/security/network.ts
0 → 100644
View file @
1fd5eed8
/**
* 网络出站安全校验工具集。
*
* 这里集中导出 URL 字符串层面的轻量校验,供 serverRequest/plusRequest 等
* 内部 helper 在调用前快速短路。更复杂的 SSRF 校验(协议白名单 + DNS +
* 内网/metadata 拦截)请使用 `common/system/utils.ts` 中的 `checkUrlSafety`。
*/
/**
* 判断给定字符串是否是"绝对 URL"。
* 命中条件:
* - 以 `scheme://` 形式开头(http://、https://、ws://、ftp:// ...)
* - 以 `//` 开头(protocol-relative,会被 axios/new URL 当成绝对 URL 处理)
*
* 校验严格的目的是阻止 helper 调用方意外把绝对 URL 传进来覆盖 baseURL,
* 即使该 helper 已经显式关闭 SSRF 拦截器也不会形成 SSRF。
*/
export
const
isAbsoluteUrl
=
(
url
:
unknown
):
boolean
=>
{
if
(
typeof
url
!==
'string'
)
return
false
;
return
/^
[
a-z
][
a-z0-9+.-
]
*:
\/\/
/i
.
test
(
url
)
||
url
.
startsWith
(
'//'
);
};
/**
* 强制要求传入的 URL 是相对路径,否则 reject。
* 适用于"按设计只访问内部固定 baseURL"的内部 helper。
*
* 例: serverRequest(本机 NextJS API)、plusRequest(商业版 Pro 服务)。
*/
export
const
assertRelativePath
=
(
url
:
unknown
,
helperName
=
'request'
):
void
=>
{
if
(
typeof
url
!==
'string'
||
isAbsoluteUrl
(
url
))
{
throw
new
Error
(
`
${
helperName
}
only accepts relative paths, absolute URLs are not allowed`
);
}
};
/**
* 在用 `new URL(path, base)` 构造目标 URL 后,强制校验最终 origin 与 base 一致。
*
* 防御 "protocol-relative URL" 主机覆盖:
* - `new URL('//169.254.169.254/foo', 'http://internal:3000')` → host 被替换
* - NextJS catch-all `[...path]` 中,`/api//evil/...` 会被拆成 `['', 'evil', ...]`,
* join 回去就构造出 `//evil/...` 这种 protocol-relative path
*
* 用法:
* const target = buildSameOriginUrl(requestPath, baseUrl); // 抛错 = 攻击
*/
export
const
buildSameOriginUrl
=
(
path
:
string
,
base
:
string
):
URL
=>
{
const
baseUrl
=
new
URL
(
base
);
const
target
=
new
URL
(
path
,
baseUrl
);
if
(
target
.
origin
!==
baseUrl
.
origin
)
{
throw
new
Error
(
`Refused: target URL origin (
${
target
.
origin
}
) does not match base (
${
baseUrl
.
origin
}
)`
);
}
return
target
;
};
packages/service/common/system/utils.ts
View file @
1fd5eed8
...
@@ -178,3 +178,30 @@ export const isInternalAddress = async (url: string): Promise<boolean> => {
...
@@ -178,3 +178,30 @@ export const isInternalAddress = async (url: string): Promise<boolean> => {
};
};
export
const
PRIVATE_URL_TEXT
=
'Request to private network not allowed'
;
export
const
PRIVATE_URL_TEXT
=
'Request to private network not allowed'
;
/**
* 用于"保存配置 URL"或"调用前校验"的统一安全检查:
* - 必须是合法 URL
* - 协议必须是 http/https
* - 不能指向内部地址(loopback/metadata,以及在 CHECK_INTERNAL_IP=true 时的私网)
*
* 注意:`isInternalAddress` 在 dev 环境直接放行;为了让保存入口
* 在 dev 也能拒绝明显错误的 URL(localhost / metadata),
* 这里**不依赖 isDevEnv**,而是用同一套规则做轻量校验。
*/
export
const
checkUrlSafety
=
async
(
url
:
string
,
fieldName
=
'URL'
):
Promise
<
void
>
=>
{
let
parsed
:
URL
;
try
{
parsed
=
new
URL
(
url
);
}
catch
{
return
Promise
.
reject
(
new
Error
(
`
${
fieldName
}
must be a valid URL`
));
}
if
(
parsed
.
protocol
!==
'http:'
&&
parsed
.
protocol
!==
'https:'
)
{
return
Promise
.
reject
(
new
Error
(
`
${
fieldName
}
must use http or https protocol`
));
}
if
(
await
isInternalAddress
(
url
))
{
return
Promise
.
reject
(
new
Error
(
`
${
fieldName
}
:
${
PRIVATE_URL_TEXT
}
`
));
}
};
packages/service/core/ai/rerank/index.ts
View file @
1fd5eed8
import
{
POST
}
from
'../../../common/api/serverRequest
'
;
import
{
axios
}
from
'../../../common/api/axios
'
;
import
{
getDefaultRerankModel
}
from
'../model'
;
import
{
getDefaultRerankModel
}
from
'../model'
;
import
{
getAxiosConfig
}
from
'../config'
;
import
{
getAxiosConfig
}
from
'../config'
;
import
{
type
RerankModelItemType
}
from
'@fastgpt/global/core/ai/model.schema'
;
import
{
type
RerankModelItemType
}
from
'@fastgpt/global/core/ai/model.schema'
;
...
@@ -93,21 +93,24 @@ export async function reRankRecall({
...
@@ -93,21 +93,24 @@ export async function reRankRecall({
const
{
baseUrl
,
authorization
}
=
getAxiosConfig
();
const
{
baseUrl
,
authorization
}
=
getAxiosConfig
();
const
start
=
Date
.
now
();
const
start
=
Date
.
now
();
const
apiResult
=
await
POST
<
PostReRankResponse
>
(
const
requestUrl
=
model
.
requestUrl
?
model
.
requestUrl
:
`
${
baseUrl
}
/rerank`
;
model
.
requestUrl
?
model
.
requestUrl
:
`
${
baseUrl
}
/rerank`
,
const
apiResult
=
await
axios
{
.
post
<
PostReRankResponse
>
(
model
:
model
.
model
,
requestUrl
,
query
,
{
documents
:
documentsTextArray
model
:
model
.
model
,
},
query
,
{
documents
:
documentsTextArray
headers
:
{
Authorization
:
model
.
requestAuth
?
`Bearer
${
model
.
requestAuth
}
`
:
authorization
,
...
headers
},
},
timeout
:
30000
{
}
headers
:
{
)
Authorization
:
model
.
requestAuth
?
`Bearer
${
model
.
requestAuth
}
`
:
authorization
,
...
headers
},
timeout
:
30000
}
)
.
then
((
res
)
=>
res
.
data
)
.
then
(
async
(
data
)
=>
{
.
then
(
async
(
data
)
=>
{
if
(
!
data
?.
results
||
data
?.
results
?.
length
===
0
)
{
if
(
!
data
?.
results
||
data
?.
results
?.
length
===
0
)
{
logger
.
error
(
'Rerank returned empty results'
,
{
data
});
logger
.
error
(
'Rerank returned empty results'
,
{
data
});
...
...
packages/service/core/ai/sandbox/toolCall/index.ts
View file @
1fd5eed8
...
@@ -6,8 +6,7 @@ import { toolMap as getFileUrlToolMap } from './getFileUrl.tool';
...
@@ -6,8 +6,7 @@ import { toolMap as getFileUrlToolMap } from './getFileUrl.tool';
import
{
toolMap
as
shellToolMap
}
from
'./shell.tool'
;
import
{
toolMap
as
shellToolMap
}
from
'./shell.tool'
;
import
{
getSandboxClient
}
from
'../controller'
;
import
{
getSandboxClient
}
from
'../controller'
;
import
{
parseJsonArgs
}
from
'../../utils'
;
import
{
parseJsonArgs
}
from
'../../utils'
;
import
{
axios
}
from
'../../../../common/api/axios'
;
import
{
pickOutboundAxios
}
from
'../../../../common/api/axios'
;
import
{
serverRequestBaseUrl
}
from
'../../../../common/api/serverRequest'
;
import
type
{
FileWriteEntry
}
from
'@fastgpt-sdk/sandbox-adapter'
;
import
type
{
FileWriteEntry
}
from
'@fastgpt-sdk/sandbox-adapter'
;
const
ToolMap
=
{
const
ToolMap
=
{
...
@@ -95,8 +94,7 @@ export const injectSandboxFiles = async ({
...
@@ -95,8 +94,7 @@ export const injectSandboxFiles = async ({
files
files
.
filter
((
file
)
=>
file
.
path
)
.
filter
((
file
)
=>
file
.
path
)
.
map
(
async
({
path
,
url
}):
Promise
<
FileWriteEntry
>
=>
{
.
map
(
async
({
path
,
url
}):
Promise
<
FileWriteEntry
>
=>
{
const
response
=
await
axios
.
get
<
ArrayBuffer
>
(
url
,
{
const
response
=
await
pickOutboundAxios
(
url
).
get
<
ArrayBuffer
>
(
url
,
{
baseURL
:
serverRequestBaseUrl
,
responseType
:
'arraybuffer'
responseType
:
'arraybuffer'
});
});
...
...
packages/service/core/workflow/dispatch/ai/agent/sub/file/index.ts
View file @
1fd5eed8
import
{
isInternalAddress
,
PRIVATE_URL_TEXT
}
from
'../../../../../../../common/system/utils'
;
import
{
isInternalAddress
,
PRIVATE_URL_TEXT
}
from
'../../../../../../../common/system/utils'
;
import
axios
from
'axios'
;
import
{
pickOutboundAxios
}
from
'../../../../../../../common/api/axios'
;
import
{
serverRequestBaseUrl
}
from
'../../../../../../../common/api/serverRequest'
;
import
{
parseFileExtensionFromUrl
}
from
'@fastgpt/global/common/string/tools'
;
import
{
parseFileExtensionFromUrl
}
from
'@fastgpt/global/common/string/tools'
;
import
{
import
{
detectFileEncoding
,
detectFileEncoding
,
...
@@ -62,9 +61,7 @@ export const dispatchFileRead = async ({
...
@@ -62,9 +61,7 @@ export const dispatchFileRead = async ({
content
:
Promise
.
reject
(
PRIVATE_URL_TEXT
)
content
:
Promise
.
reject
(
PRIVATE_URL_TEXT
)
};
};
}
}
// Get file buffer data
const
response
=
await
pickOutboundAxios
(
url
).
get
(
url
,
{
const
response
=
await
axios
.
get
(
url
,
{
baseURL
:
serverRequestBaseUrl
,
responseType
:
'arraybuffer'
responseType
:
'arraybuffer'
});
});
...
...
packages/service/core/workflow/dispatch/ai/agent/sub/sandbox/skill.ts
View file @
1fd5eed8
...
@@ -15,8 +15,7 @@ import type {
...
@@ -15,8 +15,7 @@ import type {
SandboxSearchSchema
,
SandboxSearchSchema
,
SandboxFetchUserFileSchema
SandboxFetchUserFileSchema
}
from
'@fastgpt/global/core/workflow/node/agent/skillTools'
;
}
from
'@fastgpt/global/core/workflow/node/agent/skillTools'
;
import
axios
from
'axios'
;
import
{
pickOutboundAxios
}
from
'../../../../../../../common/api/axios'
;
import
{
serverRequestBaseUrl
}
from
'../../../../../../../common/api/serverRequest'
;
import
path
from
'path'
;
import
path
from
'path'
;
type
DispatchResult
=
{
type
DispatchResult
=
{
...
@@ -200,9 +199,16 @@ export async function dispatchSandboxFetchUserFile(
...
@@ -200,9 +199,16 @@ export async function dispatchSandboxFetchUserFile(
};
};
}
}
// 拒绝 ws/wss 协议进入文件下载链路
if
(
/^wss
?
:/i
.
test
(
fileEntry
.
url
))
{
return
{
response
:
`Failed: ws/wss protocol is not allowed for file URL`
,
usages
:
[]
};
}
try
{
try
{
const
response
=
await
axios
.
get
(
fileEntry
.
url
,
{
const
response
=
await
pickOutboundAxios
(
fileEntry
.
url
).
get
(
fileEntry
.
url
,
{
baseURL
:
serverRequestBaseUrl
,
responseType
:
'arraybuffer'
responseType
:
'arraybuffer'
});
});
const
buffer
:
ArrayBuffer
=
response
.
data
;
const
buffer
:
ArrayBuffer
=
response
.
data
;
...
...
packages/service/core/workflow/utils/file.ts
View file @
1fd5eed8
...
@@ -3,8 +3,7 @@ import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
...
@@ -3,8 +3,7 @@ import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import
{
parseUrlToFileType
}
from
'./context'
;
import
{
parseUrlToFileType
}
from
'./context'
;
import
{
getS3RawTextSource
}
from
'../../../common/s3/sources/rawText'
;
import
{
getS3RawTextSource
}
from
'../../../common/s3/sources/rawText'
;
import
{
isInternalAddress
,
PRIVATE_URL_TEXT
}
from
'../../../common/system/utils'
;
import
{
isInternalAddress
,
PRIVATE_URL_TEXT
}
from
'../../../common/system/utils'
;
import
{
axios
}
from
'../../../common/api/axios'
;
import
{
pickOutboundAxios
}
from
'../../../common/api/axios'
;
import
{
serverRequestBaseUrl
}
from
'../../../common/api/serverRequest'
;
import
{
S3Buckets
}
from
'../../../common/s3/config/constants'
;
import
{
S3Buckets
}
from
'../../../common/s3/config/constants'
;
import
{
S3Sources
}
from
'../../../common/s3/contracts/type'
;
import
{
S3Sources
}
from
'../../../common/s3/contracts/type'
;
import
{
import
{
...
@@ -112,9 +111,7 @@ export const normalizeReadableFileUrl = ({
...
@@ -112,9 +111,7 @@ export const normalizeReadableFileUrl = ({
};
};
export
const
getFileInfoFromUrl
=
async
({
teamId
,
url
}:
{
teamId
:
string
;
url
:
string
})
=>
{
export
const
getFileInfoFromUrl
=
async
({
teamId
,
url
}:
{
teamId
:
string
;
url
:
string
})
=>
{
// Get file buffer data
const
response
=
await
pickOutboundAxios
(
url
).
get
(
url
,
{
const
response
=
await
axios
.
get
(
url
,
{
baseURL
:
serverRequestBaseUrl
,
responseType
:
'arraybuffer'
responseType
:
'arraybuffer'
});
});
...
...
packages/service/test/common/api/axios.test.ts
View file @
1fd5eed8
...
@@ -163,4 +163,33 @@ describe('axios.ts', () => {
...
@@ -163,4 +163,33 @@ describe('axios.ts', () => {
expect
(
axios
.
defaults
.
httpsAgent
).
toBeDefined
();
expect
(
axios
.
defaults
.
httpsAgent
).
toBeDefined
();
});
});
});
});
describe
(
'pickOutboundAxios'
,
()
=>
{
it
.
each
([
'http://example.com'
,
'https://example.com/path'
,
'http://169.254.169.254/latest/meta-data/'
,
'//attacker.example/probe'
// protocol-relative 也按绝对处理
])(
'绝对 URL %j 返回 safe axios 实例'
,
async
(
url
)
=>
{
const
{
axios
,
pickOutboundAxios
}
=
await
import
(
'@fastgpt/service/common/api/axios'
);
expect
(
pickOutboundAxios
(
url
)).
toBe
(
axios
);
});
it
.
each
([
'/api/foo'
,
'api/foo'
,
'/support/outLink/feishu/abc'
])(
'相对路径 %j 返回内部 axios(baseURL 固定到本机)'
,
async
(
url
)
=>
{
const
{
axios
,
pickOutboundAxios
}
=
await
import
(
'@fastgpt/service/common/api/axios'
);
const
client
=
pickOutboundAxios
(
url
);
expect
(
client
).
not
.
toBe
(
axios
);
expect
(
client
.
defaults
.
baseURL
).
toMatch
(
/^http:
\/\/
/
);
}
);
it
(
'多次调用同一类型的 URL,内部 client 应被复用(避免每次新建实例)'
,
async
()
=>
{
const
{
pickOutboundAxios
}
=
await
import
(
'@fastgpt/service/common/api/axios'
);
const
a
=
pickOutboundAxios
(
'/api/a'
);
const
b
=
pickOutboundAxios
(
'/api/b'
);
expect
(
a
).
toBe
(
b
);
});
});
});
});
packages/service/test/common/security/network.test.ts
0 → 100644
View file @
1fd5eed8
import
{
describe
,
it
,
expect
}
from
'vitest'
;
import
{
isAbsoluteUrl
,
assertRelativePath
,
buildSameOriginUrl
}
from
'@fastgpt/service/common/security/network'
;
describe
(
'common/security/network'
,
()
=>
{
describe
(
'isAbsoluteUrl'
,
()
=>
{
it
.
each
([
[
'http://example.com'
,
true
],
[
'https://example.com/path'
,
true
],
[
'HTTP://EXAMPLE.COM'
,
true
],
// 协议大小写不敏感
[
'ws://example.com'
,
true
],
[
'wss://example.com'
,
true
],
[
'ftp://example.com'
,
true
],
[
'file:///etc/passwd'
,
true
],
[
'javascript:alert(1)'
,
false
],
// 没有 :// 不算
[
'//example.com/path'
,
true
],
// protocol-relative
[
'//169.254.169.254/latest/meta-data/'
,
true
],
[
'/api/foo'
,
false
],
[
'api/foo'
,
false
],
[
''
,
false
],
[
'?query=1'
,
false
],
[
'#hash'
,
false
]
])(
'isAbsoluteUrl(%j) === %s'
,
(
input
,
expected
)
=>
{
expect
(
isAbsoluteUrl
(
input
)).
toBe
(
expected
);
});
it
(
'non-string 输入一律返回 false'
,
()
=>
{
expect
(
isAbsoluteUrl
(
undefined
)).
toBe
(
false
);
expect
(
isAbsoluteUrl
(
null
)).
toBe
(
false
);
expect
(
isAbsoluteUrl
(
123
)).
toBe
(
false
);
expect
(
isAbsoluteUrl
({})).
toBe
(
false
);
});
});
describe
(
'assertRelativePath'
,
()
=>
{
it
(
'相对路径不抛错'
,
()
=>
{
expect
(()
=>
assertRelativePath
(
'/api/foo'
)).
not
.
toThrow
();
expect
(()
=>
assertRelativePath
(
'api/foo'
)).
not
.
toThrow
();
expect
(()
=>
assertRelativePath
(
'support/outLink/wecom/abc'
)).
not
.
toThrow
();
});
it
.
each
([
'http://example.com'
,
'https://169.254.169.254/latest/meta-data/'
,
'//attacker.example/probe'
,
'ws://internal/socket'
])(
'绝对 URL 抛错: %j'
,
(
url
)
=>
{
expect
(()
=>
assertRelativePath
(
url
)).
toThrow
(
/only accepts relative paths/i
);
});
it
(
'non-string 抛错'
,
()
=>
{
expect
(()
=>
assertRelativePath
(
undefined
)).
toThrow
(
/only accepts relative paths/i
);
expect
(()
=>
assertRelativePath
(
null
)).
toThrow
(
/only accepts relative paths/i
);
});
it
(
'错误信息包含调用者名称,便于定位'
,
()
=>
{
expect
(()
=>
assertRelativePath
(
'http://x'
,
'plusRequest'
)).
toThrow
(
/plusRequest/
);
expect
(()
=>
assertRelativePath
(
'http://x'
,
'serverRequest'
)).
toThrow
(
/serverRequest/
);
});
});
describe
(
'buildSameOriginUrl'
,
()
=>
{
const
base
=
'http://internal-service:3000'
;
it
(
'普通相对路径正常拼接'
,
()
=>
{
const
u
=
buildSameOriginUrl
(
'/api/foo'
,
base
);
expect
(
u
.
href
).
toBe
(
'http://internal-service:3000/api/foo'
);
});
it
(
'保留 query 与 hash'
,
()
=>
{
const
u
=
buildSameOriginUrl
(
'/api/foo?x=1#bar'
,
base
);
expect
(
u
.
href
).
toBe
(
'http://internal-service:3000/api/foo?x=1#bar'
);
});
it
(
'保留 base 自带 path 的相对解析行为'
,
()
=>
{
const
u
=
buildSameOriginUrl
(
'foo'
,
'http://h:3000/api/'
);
expect
(
u
.
href
).
toBe
(
'http://h:3000/api/foo'
);
});
it
.
each
([
// protocol-relative URL 直接覆盖主机
'//169.254.169.254/latest/meta-data/'
,
'//attacker.example/probe'
,
// NextJS catch-all 拼接产物: requestPath = `/${['', 'evil', 'x'].join('/')}` = `//evil/x`
'//evil.example/path'
,
// 绝对 URL 也会替换主机
'http://attacker.example/x'
,
'https://169.254.169.254/'
,
// 协议 + 主机 + 不同端口
'http://internal-service:9999/'
])(
'protocol-relative / 绝对 URL 改写主机时抛错: %j'
,
(
path
)
=>
{
expect
(()
=>
buildSameOriginUrl
(
path
,
base
)).
toThrow
(
/does not match base/i
);
});
it
(
'host 相同但端口不同也算不同 origin'
,
()
=>
{
expect
(()
=>
buildSameOriginUrl
(
'//internal-service:9999/x'
,
base
)).
toThrow
(
/does not match base/i
);
});
it
(
'host 相同但协议不同也算不同 origin'
,
()
=>
{
expect
(()
=>
buildSameOriginUrl
(
'https://internal-service:3000/'
,
base
)).
toThrow
(
/does not match base/i
);
});
it
(
'base 非法 URL 时抛错'
,
()
=>
{
expect
(()
=>
buildSameOriginUrl
(
'/api/foo'
,
'not a url'
)).
toThrow
();
});
it
(
'NextJS catch-all 真实场景: path 含空段产生 protocol-relative'
,
()
=>
{
// 模拟 `req.query.path = ['', '169.254.169.254', 'latest']` (来源: /aiproxy//169.254.169.254/latest)
const
requestPath
=
`/
${[
''
,
'169.254.169.254'
,
'latest'
].
join
(
'/'
)}
`
;
expect
(
requestPath
).
toBe
(
'//169.254.169.254/latest'
);
expect
(()
=>
buildSameOriginUrl
(
requestPath
,
base
)).
toThrow
(
/does not match base/i
);
});
});
});
packages/service/test/core/ai/rerank/index.test.ts
View file @
1fd5eed8
...
@@ -3,17 +3,21 @@ import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants';
...
@@ -3,17 +3,21 @@ import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants';
import
type
{
RerankModelItemType
}
from
'@fastgpt/global/core/ai/model.schema'
;
import
type
{
RerankModelItemType
}
from
'@fastgpt/global/core/ai/model.schema'
;
// hoisted:让 mock 实例可在 beforeEach 中重设
// hoisted:让 mock 实例可在 beforeEach 中重设
const
{
mockCountPromptTokens
,
mock
POST
}
=
vi
.
hoisted
(()
=>
({
const
{
mockCountPromptTokens
,
mock
AxiosPost
}
=
vi
.
hoisted
(()
=>
({
mockCountPromptTokens
:
vi
.
fn
(),
mockCountPromptTokens
:
vi
.
fn
(),
mockPOST
:
vi
.
fn
()
// mockAxiosPost 接收原始 payload(即 axios response 的 .data),包装成 { data }
mockAxiosPost
:
vi
.
fn
()
}));
}));
vi
.
mock
(
'@fastgpt/service/common/string/tiktoken'
,
()
=>
({
vi
.
mock
(
'@fastgpt/service/common/string/tiktoken'
,
()
=>
({
countPromptTokens
:
mockCountPromptTokens
countPromptTokens
:
mockCountPromptTokens
}));
}));
vi
.
mock
(
'@fastgpt/service/common/api/serverRequest'
,
()
=>
({
// rerank 现在改用统一 axios(带 SSRF 拦截),mock axios.post 返回 axios 风格的 { data, ... }
POST
:
(...
args
:
any
[])
=>
mockPOST
(...
args
)
vi
.
mock
(
'@fastgpt/service/common/api/axios'
,
()
=>
({
axios
:
{
post
:
(...
args
:
any
[])
=>
Promise
.
resolve
(
mockAxiosPost
(...
args
)).
then
((
data
)
=>
({
data
}))
}
}));
}));
// Mock text2Chunks:按 chunkSize 字符切分,保证测试确定性
// Mock text2Chunks:按 chunkSize 字符切分,保证测试确定性
...
@@ -40,7 +44,7 @@ const mockModel: RerankModelItemType = {
...
@@ -40,7 +44,7 @@ const mockModel: RerankModelItemType = {
describe
(
'reRankRecall'
,
()
=>
{
describe
(
'reRankRecall'
,
()
=>
{
beforeEach
(()
=>
{
beforeEach
(()
=>
{
mock
POST
.
mockReset
();
mock
AxiosPost
.
mockReset
();
mockCountPromptTokens
.
mockReset
();
mockCountPromptTokens
.
mockReset
();
mockCountPromptTokens
.
mockImplementation
(
async
(
text
:
string
)
=>
text
.
length
);
mockCountPromptTokens
.
mockImplementation
(
async
(
text
:
string
)
=>
text
.
length
);
});
});
...
@@ -48,7 +52,7 @@ describe('reRankRecall', () => {
...
@@ -48,7 +52,7 @@ describe('reRankRecall', () => {
// ── 基础场景 ──────────────────────────────────────────────────────────────
// ── 基础场景 ──────────────────────────────────────────────────────────────
it
(
'正常场景:多文档返回正确 id 和 score'
,
async
()
=>
{
it
(
'正常场景:多文档返回正确 id 和 score'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[
results
:
[
{
index
:
1
,
relevance_score
:
0.9
},
{
index
:
1
,
relevance_score
:
0.9
},
...
@@ -73,7 +77,7 @@ describe('reRankRecall', () => {
...
@@ -73,7 +77,7 @@ describe('reRankRecall', () => {
});
});
it
(
'单文档正常召回'
,
async
()
=>
{
it
(
'单文档正常召回'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[{
index
:
0
,
relevance_score
:
0.75
}],
results
:
[{
index
:
0
,
relevance_score
:
0.75
}],
meta
:
{
tokens
:
{
input_tokens
:
10
,
output_tokens
:
0
}
}
meta
:
{
tokens
:
{
input_tokens
:
10
,
output_tokens
:
0
}
}
...
@@ -99,7 +103,7 @@ describe('reRankRecall', () => {
...
@@ -99,7 +103,7 @@ describe('reRankRecall', () => {
});
});
expect
(
result
).
toEqual
({
results
:
[],
inputTokens
:
0
});
expect
(
result
).
toEqual
({
results
:
[],
inputTokens
:
0
});
expect
(
mock
POST
).
not
.
toHaveBeenCalled
();
expect
(
mock
AxiosPost
).
not
.
toHaveBeenCalled
();
});
});
it
(
'所有文档 text 为空或空白时,返回空结果,不发请求'
,
async
()
=>
{
it
(
'所有文档 text 为空或空白时,返回空结果,不发请求'
,
async
()
=>
{
...
@@ -113,7 +117,7 @@ describe('reRankRecall', () => {
...
@@ -113,7 +117,7 @@ describe('reRankRecall', () => {
});
});
expect
(
result
).
toEqual
({
results
:
[],
inputTokens
:
0
});
expect
(
result
).
toEqual
({
results
:
[],
inputTokens
:
0
});
expect
(
mock
POST
).
not
.
toHaveBeenCalled
();
expect
(
mock
AxiosPost
).
not
.
toHaveBeenCalled
();
});
});
// ── 复杂场景:文档切分 ────────────────────────────────────────────────────
// ── 复杂场景:文档切分 ────────────────────────────────────────────────────
...
@@ -125,7 +129,7 @@ describe('reRankRecall', () => {
...
@@ -125,7 +129,7 @@ describe('reRankRecall', () => {
// doc2 'short' length=5 <= 599 → 不切分 (index 3)
// doc2 'short' length=5 <= 599 → 不切分 (index 3)
const
longText
=
'a'
.
repeat
(
1100
);
const
longText
=
'a'
.
repeat
(
1100
);
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
// API 按 score 降序返回
// API 按 score 降序返回
results
:
[
results
:
[
...
@@ -158,7 +162,7 @@ describe('reRankRecall', () => {
...
@@ -158,7 +162,7 @@ describe('reRankRecall', () => {
// maxToken=600, query='q'(1), docBudget=599, chunkSize=539
// maxToken=600, query='q'(1), docBudget=599, chunkSize=539
const
longText
=
'b'
.
repeat
(
1100
);
const
longText
=
'b'
.
repeat
(
1100
);
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[
results
:
[
{
index
:
1
,
relevance_score
:
0.95
},
// chunk_1 最高
{
index
:
1
,
relevance_score
:
0.95
},
// chunk_1 最高
...
@@ -181,7 +185,7 @@ describe('reRankRecall', () => {
...
@@ -181,7 +185,7 @@ describe('reRankRecall', () => {
// ── inputTokens 计算 ──────────────────────────────────────────────────────
// ── inputTokens 计算 ──────────────────────────────────────────────────────
it
(
'API 未返回 meta tokens 时,通过 countPromptTokens 估算'
,
async
()
=>
{
it
(
'API 未返回 meta tokens 时,通过 countPromptTokens 估算'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[{
index
:
0
,
relevance_score
:
0.5
}]
results
:
[{
index
:
0
,
relevance_score
:
0.5
}]
// 无 meta
// 无 meta
...
@@ -198,7 +202,7 @@ describe('reRankRecall', () => {
...
@@ -198,7 +202,7 @@ describe('reRankRecall', () => {
});
});
it
(
'API 返回 meta tokens 时直接使用'
,
async
()
=>
{
it
(
'API 返回 meta tokens 时直接使用'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
meta
:
{
tokens
:
{
input_tokens
:
42
,
output_tokens
:
0
}
}
meta
:
{
tokens
:
{
input_tokens
:
42
,
output_tokens
:
0
}
}
...
@@ -216,7 +220,7 @@ describe('reRankRecall', () => {
...
@@ -216,7 +220,7 @@ describe('reRankRecall', () => {
// ── requestUrl / requestAuth ──────────────────────────────────────────────
// ── requestUrl / requestAuth ──────────────────────────────────────────────
it
(
'有 requestUrl 和 requestAuth 时,使用自定义地址和认证头'
,
async
()
=>
{
it
(
'有 requestUrl 和 requestAuth 时,使用自定义地址和认证头'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
meta
:
{
tokens
:
{
input_tokens
:
5
,
output_tokens
:
0
}
}
meta
:
{
tokens
:
{
input_tokens
:
5
,
output_tokens
:
0
}
}
...
@@ -232,7 +236,7 @@ describe('reRankRecall', () => {
...
@@ -232,7 +236,7 @@ describe('reRankRecall', () => {
documents
:
[{
id
:
'doc1'
,
text
:
'hello'
}]
documents
:
[{
id
:
'doc1'
,
text
:
'hello'
}]
});
});
expect
(
mock
POST
).
toHaveBeenCalledWith
(
expect
(
mock
AxiosPost
).
toHaveBeenCalledWith
(
'https://custom.rerank.io/rerank'
,
'https://custom.rerank.io/rerank'
,
expect
.
any
(
Object
),
expect
.
any
(
Object
),
expect
.
objectContaining
({
expect
.
objectContaining
({
...
@@ -244,7 +248,7 @@ describe('reRankRecall', () => {
...
@@ -244,7 +248,7 @@ describe('reRankRecall', () => {
});
});
it
(
'未设置 requestUrl 时,使用 baseUrl/rerank'
,
async
()
=>
{
it
(
'未设置 requestUrl 时,使用 baseUrl/rerank'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
meta
:
{
tokens
:
{
input_tokens
:
5
,
output_tokens
:
0
}
}
meta
:
{
tokens
:
{
input_tokens
:
5
,
output_tokens
:
0
}
}
...
@@ -256,7 +260,7 @@ describe('reRankRecall', () => {
...
@@ -256,7 +260,7 @@ describe('reRankRecall', () => {
documents
:
[{
id
:
'doc1'
,
text
:
'hello'
}]
documents
:
[{
id
:
'doc1'
,
text
:
'hello'
}]
});
});
const
url
:
string
=
mock
POST
.
mock
.
calls
[
0
][
0
];
const
url
:
string
=
mock
AxiosPost
.
mock
.
calls
[
0
][
0
];
expect
(
url
.
endsWith
(
'/rerank'
)).
toBe
(
true
);
expect
(
url
.
endsWith
(
'/rerank'
)).
toBe
(
true
);
});
});
...
@@ -297,7 +301,7 @@ describe('reRankRecall', () => {
...
@@ -297,7 +301,7 @@ describe('reRankRecall', () => {
it
(
'docBudget === 501 时不因 query 过长 reject'
,
async
()
=>
{
it
(
'docBudget === 501 时不因 query 过长 reject'
,
async
()
=>
{
// maxToken=502, query='q'(length=1) → docBudget = 502-1 = 501 > 500 → 正常发请求
// maxToken=502, query='q'(length=1) → docBudget = 502-1 = 501 > 500 → 正常发请求
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
results
:
[{
index
:
0
,
relevance_score
:
0.5
}],
meta
:
{
tokens
:
{
input_tokens
:
5
,
output_tokens
:
0
}
}
meta
:
{
tokens
:
{
input_tokens
:
5
,
output_tokens
:
0
}
}
...
@@ -310,11 +314,11 @@ describe('reRankRecall', () => {
...
@@ -310,11 +314,11 @@ describe('reRankRecall', () => {
});
});
expect
(
result
.
results
).
toHaveLength
(
1
);
expect
(
result
.
results
).
toHaveLength
(
1
);
expect
(
mock
POST
).
toHaveBeenCalledOnce
();
expect
(
mock
AxiosPost
).
toHaveBeenCalledOnce
();
});
});
it
(
'API 请求失败时,reject 并传递原始错误'
,
async
()
=>
{
it
(
'API 请求失败时,reject 并传递原始错误'
,
async
()
=>
{
mock
POST
.
mockRejectedValueOnce
(
new
Error
(
'Network error'
));
mock
AxiosPost
.
mockRejectedValueOnce
(
new
Error
(
'Network error'
));
await
expect
(
await
expect
(
reRankRecall
({
reRankRecall
({
...
@@ -326,7 +330,7 @@ describe('reRankRecall', () => {
...
@@ -326,7 +330,7 @@ describe('reRankRecall', () => {
});
});
it
(
'API 返回空 results 时,返回空 results'
,
async
()
=>
{
it
(
'API 返回空 results 时,返回空 results'
,
async
()
=>
{
mock
POST
.
mockResolvedValueOnce
({
mock
AxiosPost
.
mockResolvedValueOnce
({
id
:
'r1'
,
id
:
'r1'
,
results
:
[]
results
:
[]
});
});
...
@@ -338,7 +342,7 @@ describe('reRankRecall', () => {
...
@@ -338,7 +342,7 @@ describe('reRankRecall', () => {
});
});
expect
(
result
.
results
).
toHaveLength
(
0
);
expect
(
result
.
results
).
toHaveLength
(
0
);
expect
(
mock
POST
).
toHaveBeenCalledOnce
();
expect
(
mock
AxiosPost
).
toHaveBeenCalledOnce
();
// 空 results 时提前返回,inputTokens 固定为 0
// 空 results 时提前返回,inputTokens 固定为 0
expect
(
result
.
inputTokens
).
toBe
(
0
);
expect
(
result
.
inputTokens
).
toBe
(
0
);
});
});
...
...
packages/service/test/core/workflow/utils/file.test.ts
View file @
1fd5eed8
...
@@ -26,10 +26,32 @@ vi.mock('@fastgpt/service/common/system/utils', async (importOriginal) => {
...
@@ -26,10 +26,32 @@ vi.mock('@fastgpt/service/common/system/utils', async (importOriginal) => {
vi
.
mock
(
'@fastgpt/service/common/api/axios'
,
async
(
importOriginal
)
=>
{
vi
.
mock
(
'@fastgpt/service/common/api/axios'
,
async
(
importOriginal
)
=>
{
const
mod
=
await
importOriginal
<
typeof
import
(
'@fastgpt/service/common/api/axios'
)
>
();
const
mod
=
await
importOriginal
<
typeof
import
(
'@fastgpt/service/common/api/axios'
)
>
();
// 把 axios 和 pickOutboundAxios 一起 mock:
// - axios: 直接换成 mock(供绝对 URL 路径)
// - pickOutboundAxios: 不论 URL 类型都返回同一个 mock client(供测试统一断言 .get 调用)
const
mockClient
=
{
get
:
mockAxiosGet
,
defaults
:
{
baseURL
:
'http://localhost:3000'
}
};
return
{
return
{
...
mod
,
...
mod
,
axios
:
{
axios
:
mockClient
,
get
:
mockAxiosGet
pickOutboundAxios
:
()
=>
mockClient
};
});
// 文件下载链路对相对路径走 raw axios + serverRequestBaseUrl,这里也 mock 住,
// 保证测试不会真发网络请求,且保留对 mockAxiosGet 调用次数的断言能力。
// outbound.ts 用 axios.create() 创建内部 client,所以 mock 必须提供 create 方法。
vi
.
mock
(
'axios'
,
()
=>
{
const
internalClient
=
{
get
:
mockAxiosGet
,
defaults
:
{
baseURL
:
'http://localhost:3000'
}
};
return
{
default
:
{
get
:
mockAxiosGet
,
create
:
vi
.
fn
(()
=>
internalClient
)
}
}
};
};
});
});
...
@@ -409,8 +431,9 @@ describe('parseFileInfoFromUrls', () => {
...
@@ -409,8 +431,9 @@ describe('parseFileInfoFromUrls', () => {
});
});
expect
(
mockAxiosGet
).
toHaveBeenCalledTimes
(
1
);
expect
(
mockAxiosGet
).
toHaveBeenCalledTimes
(
1
);
// 注:相对路径走 axios.create({ baseURL }) 创建的内部 client,
// baseURL 在 client 上而不在 .get() 调用参数里。
expect
(
mockAxiosGet
).
toHaveBeenCalledWith
(
'/report.pdf'
,
{
expect
(
mockAxiosGet
).
toHaveBeenCalledWith
(
'/report.pdf'
,
{
baseURL
:
expect
.
any
(
String
),
responseType
:
'arraybuffer'
responseType
:
'arraybuffer'
});
});
expect
(
mockReadFileContentByBuffer
).
not
.
toHaveBeenCalled
();
expect
(
mockReadFileContentByBuffer
).
not
.
toHaveBeenCalled
();
...
...
projects/app/src/pages/api/aiproxy/[...path].ts
View file @
1fd5eed8
import
type
{
NextApiRequest
,
NextApiResponse
}
from
'next'
;
import
type
{
NextApiRequest
,
NextApiResponse
}
from
'next'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
authSystemAdmin
}
from
'@fastgpt/service/support/permission/user/auth'
;
import
{
authSystemAdmin
}
from
'@fastgpt/service/support/permission/user/auth'
;
import
{
buildSameOriginUrl
}
from
'@fastgpt/service/common/security/network'
;
import
{
Readable
}
from
'stream'
;
import
{
Readable
}
from
'stream'
;
const
baseUrl
=
process
.
env
.
AIPROXY_API_ENDPOINT
;
const
baseUrl
=
process
.
env
.
AIPROXY_API_ENDPOINT
;
...
@@ -30,7 +31,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
...
@@ -30,7 +31,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const
basePath
=
`/
${
path
?.
join
(
'/'
)}
$
{
endPathMap
[
path
?.
join
(
'/'
)]
?
'/'
:
''
}
`;
const
basePath
=
`/
${
path
?.
join
(
'/'
)}
$
{
endPathMap
[
path
?.
join
(
'/'
)]
?
'/'
:
''
}
`;
const requestPath = queryStr ? `
$
{
basePath
}?
$
{
queryStr
}
` : basePath;
const requestPath = queryStr ? `
$
{
basePath
}?
$
{
queryStr
}
` : basePath;
const targetUrl = new URL(requestPath, baseUrl);
// 防御 protocol-relative URL 覆盖主机(如 path 含空段 → `
//169.254...`)
const
targetUrl
=
buildSameOriginUrl
(
requestPath
,
baseUrl
);
const
headers
:
Record
<
string
,
string
>
=
{};
const
headers
:
Record
<
string
,
string
>
=
{};
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
...
...
projects/app/src/pages/api/lafApi/[...path].ts
View file @
1fd5eed8
import
type
{
NextApiRequest
,
NextApiResponse
}
from
'next'
;
import
type
{
NextApiRequest
,
NextApiResponse
}
from
'next'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
buildSameOriginUrl
}
from
'@fastgpt/service/common/security/network'
;
import
{
Readable
}
from
'stream'
;
import
{
Readable
}
from
'stream'
;
export
default
async
function
handler
(
req
:
NextApiRequest
,
res
:
NextApiResponse
)
{
export
default
async
function
handler
(
req
:
NextApiRequest
,
res
:
NextApiResponse
)
{
...
@@ -21,7 +22,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
...
@@ -21,7 +22,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error('lafEnv is empty');
throw new Error('lafEnv is empty');
}
}
const targetUrl = new URL(requestPath, lafEnv);
// 防御 protocol-relative URL 覆盖主机(如 path 含空段 → `
//169.254...`)
const
targetUrl
=
buildSameOriginUrl
(
requestPath
,
lafEnv
);
const
headers
:
Record
<
string
,
string
>
=
{};
const
headers
:
Record
<
string
,
string
>
=
{};
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
...
...
projects/app/src/pages/api/marketplace/[...path].ts
View file @
1fd5eed8
...
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
...
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
Readable
}
from
'stream'
;
import
{
Readable
}
from
'stream'
;
import
{
authSystemAdmin
}
from
'@fastgpt/service/support/permission/user/auth'
;
import
{
authSystemAdmin
}
from
'@fastgpt/service/support/permission/user/auth'
;
import
{
buildSameOriginUrl
}
from
'@fastgpt/service/common/security/network'
;
export
default
async
function
handler
(
req
:
NextApiRequest
,
res
:
NextApiResponse
)
{
export
default
async
function
handler
(
req
:
NextApiRequest
,
res
:
NextApiResponse
)
{
try
{
try
{
...
@@ -23,7 +24,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
...
@@ -23,7 +24,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error('MARKETPLACE_URL is not configured');
throw new Error('MARKETPLACE_URL is not configured');
}
}
const targetUrl = new URL(requestPath, marketplaceUrl);
// 防御 protocol-relative URL 覆盖主机(如 path 含空段 → `
//169.254...`)
const
targetUrl
=
buildSameOriginUrl
(
requestPath
,
marketplaceUrl
);
const
headers
:
Record
<
string
,
string
>
=
{};
const
headers
:
Record
<
string
,
string
>
=
{};
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
...
...
projects/app/src/pages/api/proApi/[...path].ts
View file @
1fd5eed8
import
type
{
NextApiRequest
,
NextApiResponse
}
from
'next'
;
import
type
{
NextApiRequest
,
NextApiResponse
}
from
'next'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
jsonRes
}
from
'@fastgpt/service/common/response'
;
import
{
FastGPTProUrl
}
from
'@fastgpt/service/common/system/constants'
;
import
{
FastGPTProUrl
}
from
'@fastgpt/service/common/system/constants'
;
import
{
buildSameOriginUrl
}
from
'@fastgpt/service/common/security/network'
;
import
{
Readable
}
from
'stream'
;
import
{
Readable
}
from
'stream'
;
export
default
async
function
handler
(
req
:
NextApiRequest
,
res
:
NextApiResponse
)
{
export
default
async
function
handler
(
req
:
NextApiRequest
,
res
:
NextApiResponse
)
{
...
@@ -15,7 +16,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
...
@@ -15,7 +16,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
throw new Error(`
未配置商业版链接
:
$
{
path
}
`);
throw new Error(`
未配置商业版链接
:
$
{
path
}
`);
}
}
const targetUrl = new URL(requestPath, FastGPTProUrl);
// 防御 protocol-relative URL 覆盖主机(如 path 含空段 → `
//169.254...`)
const
targetUrl
=
buildSameOriginUrl
(
requestPath
,
FastGPTProUrl
);
const
headers
:
Record
<
string
,
string
>
=
{};
const
headers
:
Record
<
string
,
string
>
=
{};
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
for
(
const
[
key
,
value
]
of
Object
.
entries
(
req
.
headers
))
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment