Commit 0a38565c by Archer Committed by GitHub

fix: pin DNS for safe axios direct requests (#7261)

* fix: pin DNS for safe axios direct requests

* docs: add upgrade notes

* ui

* submodule

* fix: method

* update package
parent 7f4329d9
---
title: 'V4.14.28'
description: 'FastGPT V4.14.28 release notes'
---
## Upgrade Guide
### 1. Update image tags
- Update the fastgpt-app image tag (FastGPT main service): v4.14.28
- Update the fastgpt-pro image tag (FastGPT commercial edition): v4.14.28
## Changes
1. Fixed a Node.js version compatibility issue in the admin service to prevent service errors caused by mismatched Node.js runtime versions.
---
title: 'V4.14.28'
description: 'FastGPT V4.14.28 更新说明'
---
## 升级指南
### 1. 更新镜像 tag
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.28
- 更新 fastgpt-pro(fastgpt 商业版) 镜像 tag: v4.14.28
## 变更说明
1. 修复 admin 服务 Node.js 版本兼容问题,避免因运行环境 Node.js 版本不匹配导致服务异常。
---
title: 'V4.14.29'
description: 'FastGPT V4.14.29 release notes'
---
## Upgrade Guide
### 1. Update image tags
- Update the fastgpt-app image tag (FastGPT main service): v4.14.29
- Update the fastgpt-pro image tag (FastGPT commercial edition): v4.14.29
## Changes
1. Fixed permission checks for the WeChat publish channel login, logout, and QR code status APIs. These APIs now authorize by publish channel ID and verify the channel type to avoid accidentally operating on non-WeChat publish channels.
2. Adapted to the latest WeChat publish channel SDK.
---
title: 'V4.14.29'
description: 'FastGPT V4.14.29 更新说明'
---
## 升级指南
### 1. 更新镜像 tag
- 更新 fastgpt-app(fastgpt 主服务) 镜像 tag: v4.14.29
- 更新 fastgpt-pro(fastgpt 商业版) 镜像 tag: v4.14.29
## 变更说明
1. 修复微信发布渠道登录、登出和二维码状态接口的权限校验,改为使用发布渠道 ID 鉴权,并校验渠道类型,避免非微信发布渠道被误操作。
2. 适配最新微信发布渠道 sdk。
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
"title": "4.14.x", "title": "4.14.x",
"description": "", "description": "",
"pages": [ "pages": [
"41429",
"41428",
"41427", "41427",
"41426", "41426",
"41425", "41425",
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
"title": "4.14.x", "title": "4.14.x",
"description": "", "description": "",
"pages": [ "pages": [
"41429",
"41428",
"41427", "41427",
"41426", "41426",
"41425", "41425",
......
--- ---
title: 'V4.15.1 (In Progress)' title: 'V4.15.1'
description: 'FastGPT V4.15.1 Release Notes' description: 'FastGPT V4.15.1 Release Notes'
--- ---
......
--- ---
title: 'V4.15.1(进行中)' title: 'V4.15.1'
description: 'FastGPT V4.15.1 更新说明' description: 'FastGPT V4.15.1 更新说明'
--- ---
......
---
title: 'V4.15.2(进行中)'
description: 'FastGPT V4.15.2 更新说明'
---
## 🚀 新增内容
## ⚙️ 优化
1. Skill 未关联应用时的删除弹窗文案。
2. 适配最新微信发布渠道 sdk。
## 🐛 修复
## 🛠️ 代码优化
{ {
"title": "4.15.x", "title": "4.15.x",
"description": "", "description": "",
"pages": ["4151", "41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"] "pages": ["4151", "4152", "41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"]
} }
{ {
"title": "4.15.x", "title": "4.15.x",
"description": "", "description": "",
"pages": ["4151","41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"] "pages": ["4151", "4152", "41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"]
} }
...@@ -142,6 +142,8 @@ description: FastGPT Toc ...@@ -142,6 +142,8 @@ description: FastGPT Toc
- [/en/self-host/upgrading/4-14/41425](/en/self-host/upgrading/4-14/41425) - [/en/self-host/upgrading/4-14/41425](/en/self-host/upgrading/4-14/41425)
- [/en/self-host/upgrading/4-14/41426](/en/self-host/upgrading/4-14/41426) - [/en/self-host/upgrading/4-14/41426](/en/self-host/upgrading/4-14/41426)
- [/en/self-host/upgrading/4-14/41427](/en/self-host/upgrading/4-14/41427) - [/en/self-host/upgrading/4-14/41427](/en/self-host/upgrading/4-14/41427)
- [/en/self-host/upgrading/4-14/41428](/en/self-host/upgrading/4-14/41428)
- [/en/self-host/upgrading/4-14/41429](/en/self-host/upgrading/4-14/41429)
- [/en/self-host/upgrading/4-14/4143](/en/self-host/upgrading/4-14/4143) - [/en/self-host/upgrading/4-14/4143](/en/self-host/upgrading/4-14/4143)
- [/en/self-host/upgrading/4-14/4144](/en/self-host/upgrading/4-14/4144) - [/en/self-host/upgrading/4-14/4144](/en/self-host/upgrading/4-14/4144)
- [/en/self-host/upgrading/4-14/4145](/en/self-host/upgrading/4-14/4145) - [/en/self-host/upgrading/4-14/4145](/en/self-host/upgrading/4-14/4145)
......
...@@ -144,6 +144,8 @@ description: FastGPT 文档目录 ...@@ -144,6 +144,8 @@ description: FastGPT 文档目录
- [/self-host/upgrading/4-14/41425](/self-host/upgrading/4-14/41425) - [/self-host/upgrading/4-14/41425](/self-host/upgrading/4-14/41425)
- [/self-host/upgrading/4-14/41426](/self-host/upgrading/4-14/41426) - [/self-host/upgrading/4-14/41426](/self-host/upgrading/4-14/41426)
- [/self-host/upgrading/4-14/41427](/self-host/upgrading/4-14/41427) - [/self-host/upgrading/4-14/41427](/self-host/upgrading/4-14/41427)
- [/self-host/upgrading/4-14/41428](/self-host/upgrading/4-14/41428)
- [/self-host/upgrading/4-14/41429](/self-host/upgrading/4-14/41429)
- [/self-host/upgrading/4-14/4143](/self-host/upgrading/4-14/4143) - [/self-host/upgrading/4-14/4143](/self-host/upgrading/4-14/4143)
- [/self-host/upgrading/4-14/4144](/self-host/upgrading/4-14/4144) - [/self-host/upgrading/4-14/4144](/self-host/upgrading/4-14/4144)
- [/self-host/upgrading/4-14/4145](/self-host/upgrading/4-14/4145) - [/self-host/upgrading/4-14/4145](/self-host/upgrading/4-14/4145)
...@@ -162,6 +164,7 @@ description: FastGPT 文档目录 ...@@ -162,6 +164,7 @@ description: FastGPT 文档目录
- [/self-host/upgrading/4-15/41506](/self-host/upgrading/4-15/41506) - [/self-host/upgrading/4-15/41506](/self-host/upgrading/4-15/41506)
- [/self-host/upgrading/4-15/41507](/self-host/upgrading/4-15/41507) - [/self-host/upgrading/4-15/41507](/self-host/upgrading/4-15/41507)
- [/self-host/upgrading/4-15/4151](/self-host/upgrading/4-15/4151) - [/self-host/upgrading/4-15/4151](/self-host/upgrading/4-15/4151)
- [/self-host/upgrading/4-15/4152](/self-host/upgrading/4-15/4152)
- [/self-host/upgrading/outdated/40](/self-host/upgrading/outdated/40) - [/self-host/upgrading/outdated/40](/self-host/upgrading/outdated/40)
- [/self-host/upgrading/outdated/41](/self-host/upgrading/outdated/41) - [/self-host/upgrading/outdated/41](/self-host/upgrading/outdated/41)
- [/self-host/upgrading/outdated/4100](/self-host/upgrading/outdated/4100) - [/self-host/upgrading/outdated/4100](/self-host/upgrading/outdated/4100)
......
...@@ -203,8 +203,8 @@ ...@@ -203,8 +203,8 @@
"content/self-host/custom-models/ollama.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/ollama.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/xinference.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/xinference.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/custom-models/xinference.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/deploy/docker.en.mdx": "2026-07-03T23:51:40+08:00", "content/self-host/deploy/docker.en.mdx": "2026-07-04T00:03:29+08:00",
"content/self-host/deploy/docker.mdx": "2026-07-03T23:51:40+08:00", "content/self-host/deploy/docker.mdx": "2026-07-04T00:03:29+08:00",
"content/self-host/deploy/sealos.en.mdx": "2026-06-30T22:10:03+08:00", "content/self-host/deploy/sealos.en.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/deploy/sealos.mdx": "2026-06-30T22:10:03+08:00", "content/self-host/deploy/sealos.mdx": "2026-06-30T22:10:03+08:00",
"content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -314,8 +314,8 @@ ...@@ -314,8 +314,8 @@
"content/self-host/upgrading/4-15/41506.mdx": "2026-07-01T12:13:58+08:00", "content/self-host/upgrading/4-15/41506.mdx": "2026-07-01T12:13:58+08:00",
"content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T17:31:43+08:00", "content/self-host/upgrading/4-15/41507.en.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00", "content/self-host/upgrading/4-15/41507.mdx": "2026-06-30T17:31:43+08:00",
"content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-03T22:44:25+08:00", "content/self-host/upgrading/4-15/4151.en.mdx": "2026-07-04T00:03:29+08:00",
"content/self-host/upgrading/4-15/4151.mdx": "2026-07-03T22:44:25+08:00", "content/self-host/upgrading/4-15/4151.mdx": "2026-07-04T00:03:29+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -4,9 +4,14 @@ import _, { ...@@ -4,9 +4,14 @@ import _, {
type AxiosResponse, type AxiosResponse,
type InternalAxiosRequestConfig type InternalAxiosRequestConfig
} from 'axios'; } from 'axios';
import dns from 'dns/promises';
import http from 'http';
import https from 'https';
import { isIP } from 'net';
import { ProxyAgent, type ProxyAgentOptions } from 'proxy-agent'; import { ProxyAgent, type ProxyAgentOptions } from 'proxy-agent';
import { getProxyForUrl } from 'proxy-from-env';
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, isInternalResolvedIP, PRIVATE_URL_TEXT } from '../system/utils';
import { isAbsoluteUrl } from '../security/network'; import { isAbsoluteUrl } from '../security/network';
import { SERVICE_LOCAL_HOST } from '../system/tools'; import { SERVICE_LOCAL_HOST } from '../system/tools';
...@@ -21,32 +26,27 @@ import { SERVICE_LOCAL_HOST } from '../system/tools'; ...@@ -21,32 +26,27 @@ import { SERVICE_LOCAL_HOST } from '../system/tools';
const addSSRFInterceptor = (instance: AxiosInstance) => { const addSSRFInterceptor = (instance: AxiosInstance) => {
instance.interceptors.request.use(async (config): Promise<InternalAxiosRequestConfig> => { instance.interceptors.request.use(async (config): Promise<InternalAxiosRequestConfig> => {
const safeConfig = config as SafeRedirectInternalConfig; const safeConfig = config as SafeRedirectInternalConfig;
const requestUrl = buildRequestUrl(safeConfig); const preparedConfig = await prepareSafeRequestConfig(safeConfig);
if (!requestUrl) return config;
if (await isInternalAddress(requestUrl)) {
return Promise.reject(new Error(PRIVATE_URL_TEXT));
}
const maxRedirects = const maxRedirects =
safeConfig.__safeRedirect?.maxRedirects ?? preparedConfig.__safeRedirect?.maxRedirects ??
(typeof safeConfig.maxRedirects === 'number' (typeof preparedConfig.maxRedirects === 'number'
? safeConfig.maxRedirects ? preparedConfig.maxRedirects
: SAFE_AXIOS_MAX_REDIRECTS); : SAFE_AXIOS_MAX_REDIRECTS);
const nextConfig: SafeRedirectInternalConfig = { const nextConfigWithRedirect: SafeRedirectInternalConfig = {
...safeConfig, ...preparedConfig,
// 禁用底层自动跳转,保留调用方 maxRedirects 语义给手动跳转状态使用。 // 禁用底层自动跳转,保留调用方 maxRedirects 语义给手动跳转状态使用。
maxRedirects: 0, maxRedirects: 0,
validateStatus: getRedirectValidateStatus(safeConfig.validateStatus, maxRedirects), validateStatus: getRedirectValidateStatus(preparedConfig.validateStatus, maxRedirects),
__safeRedirect: safeConfig.__safeRedirect ?? { __safeRedirect: preparedConfig.__safeRedirect ?? {
count: 0, count: 0,
maxRedirects, maxRedirects,
validateStatus: safeConfig.validateStatus validateStatus: preparedConfig.validateStatus
} }
}; };
return nextConfig; return nextConfigWithRedirect;
}); });
instance.interceptors.response.use(async (response) => { instance.interceptors.response.use(async (response) => {
...@@ -89,6 +89,14 @@ const createProxyAgent = (options?: ProxyAgentOptions) => new ProxyAgent(options ...@@ -89,6 +89,14 @@ const createProxyAgent = (options?: ProxyAgentOptions) => new ProxyAgent(options
const SAFE_AXIOS_MAX_REDIRECTS = 5; const SAFE_AXIOS_MAX_REDIRECTS = 5;
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
export type SafeAxiosRequestOptions = {
rejectUnauthorized?: boolean;
};
export type SafeAxiosRequestConfig = AxiosRequestConfig & {
__safeAxios?: SafeAxiosRequestOptions;
};
/** /**
* 手动重定向状态。 * 手动重定向状态。
* *
...@@ -104,6 +112,7 @@ type SafeRedirectState = { ...@@ -104,6 +112,7 @@ type SafeRedirectState = {
type SafeRedirectConfig = AxiosRequestConfig & { type SafeRedirectConfig = AxiosRequestConfig & {
__safeRedirect?: SafeRedirectState; __safeRedirect?: SafeRedirectState;
__safeAxios?: SafeAxiosRequestOptions;
}; };
type SafeRedirectInternalConfig = InternalAxiosRequestConfig & { type SafeRedirectInternalConfig = InternalAxiosRequestConfig & {
...@@ -112,8 +121,137 @@ type SafeRedirectInternalConfig = InternalAxiosRequestConfig & { ...@@ -112,8 +121,137 @@ type SafeRedirectInternalConfig = InternalAxiosRequestConfig & {
maxRedirects: number; maxRedirects: number;
validateStatus?: AxiosRequestConfig['validateStatus']; validateStatus?: AxiosRequestConfig['validateStatus'];
}; };
__safeAxios?: SafeAxiosRequestOptions;
};
type ResolvedAddress = {
address: string;
family: 4 | 6;
};
type LookupCallback = (
err: NodeJS.ErrnoException | null,
address: string | ResolvedAddress[],
family?: number
) => void;
/**
* 对 safe axios 的请求做统一 SSRF 预检,并在直连路径固定 DNS 解析结果。
*
* 代理路径只做本机 isInternalAddress() best-effort 检查;最终 DNS 解析由代理侧负责。
* 直连路径必须覆盖调用方 agent,避免自定义 lookup 在建连阶段重新指向内部地址。
*/
const prepareSafeRequestConfig = async (
config: SafeRedirectInternalConfig
): Promise<SafeRedirectInternalConfig> => {
const requestUrl = buildRequestUrl(config);
if (!requestUrl) return config;
if (await isInternalAddress(requestUrl)) {
return Promise.reject(new Error(PRIVATE_URL_TEXT));
}
if (isRequestUsingProxy(requestUrl, config)) {
return config;
}
return attachPinnedAgent(config, requestUrl);
};
/**
* 判断当前请求是否应保留代理链路。
*
* 显式 axios proxy 和 proxy-from-env 命中的环境代理都视为代理路径;其它自定义
* httpAgent/httpsAgent 不再被 safe axios 信任,直连路径会统一覆盖。
*/
const isRequestUsingProxy = (requestUrl: string, config: AxiosRequestConfig): boolean => {
if (config.proxy) {
return true;
}
try {
return getProxyForUrl(requestUrl).length > 0;
} catch {
// 代理判断失败时按可能走代理处理,避免误覆盖代理配置。
return true;
}
}; };
const attachPinnedAgent = async (
config: SafeRedirectInternalConfig,
requestUrl: string
): Promise<SafeRedirectInternalConfig> => {
let parsedUrl: URL;
try {
parsedUrl = new URL(requestUrl);
} catch {
return config;
}
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
return config;
}
const resolved = await resolveSafeConnectAddress(parsedUrl.hostname);
const lookup = createPinnedLookup(resolved);
const isHttps = parsedUrl.protocol === 'https:';
const agent = isHttps
? new https.Agent({
lookup,
rejectUnauthorized: config.__safeAxios?.rejectUnauthorized ?? true
})
: new http.Agent({
lookup
});
return {
...config,
proxy: false,
httpAgent: isHttps ? undefined : agent,
httpsAgent: isHttps ? agent : undefined
};
};
const resolveSafeConnectAddress = async (hostname: string): Promise<ResolvedAddress> => {
const ipFamily = isIP(hostname);
if (ipFamily) {
return {
address: hostname,
family: ipFamily as 4 | 6
};
}
const resolved = await dns.lookup(hostname, { all: true, verbatim: true });
if (resolved.length === 0) {
return Promise.reject(new Error('DNS lookup returned no address'));
}
if (resolved.some(({ address }) => isInternalResolvedIP(address))) {
return Promise.reject(new Error(PRIVATE_URL_TEXT));
}
return resolved[0] as ResolvedAddress;
};
const createPinnedLookup =
(resolved: ResolvedAddress) =>
(_hostname: string, optionsOrCallback: unknown, maybeCallback?: LookupCallback): void => {
const options = typeof optionsOrCallback === 'function' ? {} : optionsOrCallback || {};
const callback =
typeof optionsOrCallback === 'function'
? (optionsOrCallback as LookupCallback)
: maybeCallback;
if (!callback) return;
if ((options as { all?: boolean }).all) {
callback(null, [resolved]);
return;
}
callback(null, resolved.address, resolved.family);
};
const shouldRedirect = (response: AxiosResponse): boolean => const shouldRedirect = (response: AxiosResponse): boolean =>
REDIRECT_STATUS_CODES.has(response.status) && typeof response.headers.location === 'string'; REDIRECT_STATUS_CODES.has(response.status) && typeof response.headers.location === 'string';
...@@ -247,8 +385,10 @@ const getRedirectValidateStatus = ( ...@@ -247,8 +385,10 @@ const getRedirectValidateStatus = (
}; };
/** /**
* 工作流 HTTP 节点跳过 HTTPS 证书校验专用 agent。 * 兼容旧调用方的跳过 HTTPS 证书校验专用 agent。
* 仍复用 ProxyAgent,只调整目标站 TLS 校验策略,避免改变部署环境的代理语义。 *
* safe axios 的直连路径会覆盖调用方 agent;新代码需要通过 `__safeAxios`
* 传递受控 TLS 选项,避免自定义 agent 绕过 DNS pinning。
*/ */
export const httpsCertificateIgnoreAgent = createProxyAgent({ export const httpsCertificateIgnoreAgent = createProxyAgent({
rejectUnauthorized: false rejectUnauthorized: false
......
import type { NextApiResponse } from 'next';
import type { ApiRequestProps } from '../../type/next';
import { jsonRes } from '../response';
type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
/**
* 限制 NextAPI 路由允许的 HTTP method。
* method 不匹配时直接返回 405,并写入 Allow header,避免后续业务 handler 继续执行。
*/
export function useAllowedMethods(...methods: HttpMethod[]) {
const allowedMethods = methods.map((method) => method.toUpperCase());
return async (req: ApiRequestProps, res: NextApiResponse) => {
const requestMethod = req.method?.toUpperCase() ?? '';
if (allowedMethods.includes(requestMethod)) {
return;
}
res.setHeader('Allow', allowedMethods.join(', '));
jsonRes(res, {
code: 405,
message: `Method ${requestMethod || 'UNKNOWN'} Not Allowed`
});
};
}
...@@ -4,11 +4,11 @@ import { ...@@ -4,11 +4,11 @@ import {
} from '@fastgpt/global/common/system/network'; } from '@fastgpt/global/common/system/network';
import { serviceEnv } from '../../env'; import { serviceEnv } from '../../env';
const { isInternalAddress } = createInternalAddressChecker({ const { isInternalAddress, isInternalResolvedIP } = createInternalAddressChecker({
checkInternalIp: () => serviceEnv.CHECK_INTERNAL_IP checkInternalIp: () => serviceEnv.CHECK_INTERNAL_IP
}); });
export { isInternalAddress, PRIVATE_URL_TEXT }; export { isInternalAddress, isInternalResolvedIP, PRIVATE_URL_TEXT };
/** /**
* 用于"保存配置 URL"或"调用前校验"的统一安全检查: * 用于"保存配置 URL"或"调用前校验"的统一安全检查:
......
...@@ -27,7 +27,7 @@ import { getLogger, LogCategories } from '../../../../common/logger'; ...@@ -27,7 +27,7 @@ import { getLogger, LogCategories } from '../../../../common/logger';
import { formatHttpError, getNodeErrResponse } from '../utils'; import { formatHttpError, getNodeErrResponse } from '../utils';
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils'; import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils';
import { serviceRequestMaxContentLength } from '../../../../common/system/constants'; import { serviceRequestMaxContentLength } from '../../../../common/system/constants';
import { axios, httpsCertificateIgnoreAgent } from '../../../../common/api/axios'; import { axios, type SafeAxiosRequestConfig } from '../../../../common/api/axios';
import { replaceEditorVariable } from '../utils/replaceEditorVariable'; import { replaceEditorVariable } from '../utils/replaceEditorVariable';
import { checkStrOversize, logOversizeString } from '../../../../common/string/replaceVariable'; import { checkStrOversize, logOversizeString } from '../../../../common/string/replaceVariable';
import { getWorkflowAppId } from '../utils/source'; import { getWorkflowAppId } from '../utils/source';
...@@ -40,7 +40,7 @@ const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS); ...@@ -40,7 +40,7 @@ const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS);
*/ */
export const getWorkflowHttpNodeHttpsAgentConfig = ( export const getWorkflowHttpNodeHttpsAgentConfig = (
url: string url: string
): Pick<AxiosRequestConfig, 'httpsAgent'> => { ): Pick<SafeAxiosRequestConfig, '__safeAxios'> => {
const ignoreHttpsCertificate = const ignoreHttpsCertificate =
global.systemEnv?.workflowHttpNode?.ignoreHttpsCertificate === true; global.systemEnv?.workflowHttpNode?.ignoreHttpsCertificate === true;
...@@ -57,7 +57,9 @@ export const getWorkflowHttpNodeHttpsAgentConfig = ( ...@@ -57,7 +57,9 @@ export const getWorkflowHttpNodeHttpsAgentConfig = (
} }
return { return {
httpsAgent: httpsCertificateIgnoreAgent __safeAxios: {
rejectUnauthorized: false
}
}; };
}; };
......
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
import http from 'http'; import http from 'http';
import os from 'os'; import os from 'os';
import dns from 'dns/promises';
import { createProxyAxios } from '../../../common/api/axios'; import { createProxyAxios } from '../../../common/api/axios';
import { PRIVATE_URL_TEXT } from '../../../common/system/utils'; import { PRIVATE_URL_TEXT } from '../../../common/system/utils';
import { serviceEnv } from '../../../env'; import { serviceEnv } from '../../../env';
...@@ -17,11 +18,47 @@ type AxiosRequestConfig = { ...@@ -17,11 +18,47 @@ type AxiosRequestConfig = {
describe('axios.ts', () => { describe('axios.ts', () => {
const mutableServiceEnv = serviceEnv as { CHECK_INTERNAL_IP: boolean }; const mutableServiceEnv = serviceEnv as { CHECK_INTERNAL_IP: boolean };
const originalCheckInternalIp = serviceEnv.CHECK_INTERNAL_IP; const originalCheckInternalIp = serviceEnv.CHECK_INTERNAL_IP;
const proxyEnvKeys = [
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'http_proxy',
'https_proxy',
'all_proxy',
'no_proxy',
'npm_config_http_proxy',
'npm_config_https_proxy',
'npm_config_proxy',
'npm_config_no_proxy'
] as const;
const originalProxyEnv = proxyEnvKeys.reduce(
(acc, key) => {
acc[key] = process.env[key];
return acc;
},
{} as Record<(typeof proxyEnvKeys)[number], string | undefined>
);
afterEach(() => { afterEach(() => {
mutableServiceEnv.CHECK_INTERNAL_IP = originalCheckInternalIp; mutableServiceEnv.CHECK_INTERNAL_IP = originalCheckInternalIp;
vi.restoreAllMocks();
for (const key of proxyEnvKeys) {
const value = originalProxyEnv[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}); });
const clearProxyEnv = () => {
for (const key of proxyEnvKeys) {
delete process.env[key];
}
};
describe('createProxyAxios', () => { describe('createProxyAxios', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
...@@ -130,6 +167,11 @@ describe('axios.ts', () => { ...@@ -130,6 +167,11 @@ describe('axios.ts', () => {
}); });
it('应该允许公网地址继续进入 adapter', async () => { it('应该允许公网地址继续进入 adapter', async () => {
vi.spyOn(dns, 'resolve4').mockResolvedValue(['8.8.8.8']);
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records'));
vi.spyOn(dns, 'lookup').mockImplementation(
async () => [{ address: '8.8.8.8', family: 4 }] as any
);
const adapter = vi.fn().mockResolvedValue({ const adapter = vi.fn().mockResolvedValue({
data: { ok: true }, data: { ok: true },
status: 200, status: 200,
...@@ -306,6 +348,204 @@ describe('axios.ts', () => { ...@@ -306,6 +348,204 @@ describe('axios.ts', () => {
}); });
}); });
describe('safe axios DNS pinning', () => {
const listen = (handler: http.RequestListener, host = '127.0.0.1') =>
new Promise<http.Server>((resolve) => {
const server = http.createServer(handler);
server.listen(0, host, () => resolve(server));
});
const closeServer = (server: http.Server) =>
new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
const getServerPort = (server: http.Server): number => {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Invalid test server address');
}
return address.port;
};
const getReachablePrivateHost = () => {
const interfaces = os.networkInterfaces();
for (const items of Object.values(interfaces)) {
for (const item of items || []) {
if (item.family === 'IPv4' && !item.internal) {
return item.address;
}
}
}
return undefined;
};
it('应该拒绝直连路径中预检后重绑定到 loopback 的地址', async () => {
clearProxyEnv();
mutableServiceEnv.CHECK_INTERNAL_IP = false;
vi.spyOn(dns, 'resolve4').mockResolvedValue(['8.8.8.8']);
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records'));
vi.spyOn(dns, 'lookup').mockImplementation(
async () => [{ address: '127.0.0.1', family: 4 }] as any
);
const protectedServer = await listen((req, res) => {
res.end('FASTGPT-INTERNAL-CANARY');
});
const protectedPort = getServerPort(protectedServer);
try {
const instance = createProxyAxios();
await expect(
instance.get(`http://rebind-fastgpt.test:${protectedPort}/secret`)
).rejects.toThrow(PRIVATE_URL_TEXT);
} finally {
await closeServer(protectedServer);
}
});
it('应该覆盖直连路径调用方传入的自定义 lookup agent', async () => {
clearProxyEnv();
mutableServiceEnv.CHECK_INTERNAL_IP = false;
const carrierHost = getReachablePrivateHost();
if (!carrierHost) {
return;
}
vi.spyOn(dns, 'resolve4').mockResolvedValue([carrierHost]);
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records'));
vi.spyOn(dns, 'lookup').mockImplementation(
async () => [{ address: carrierHost, family: 4 }] as any
);
const targetServer = await listen((req, res) => {
res.end(JSON.stringify({ host: req.headers.host, url: req.url }));
}, '0.0.0.0');
const targetPort = getServerPort(targetServer);
const maliciousAgent = new http.Agent({
lookup: (_hostname, optionsOrCallback, maybeCallback) => {
const callback =
typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback;
callback?.(null, '127.0.0.1', 4);
}
});
try {
const instance = createProxyAxios();
const response = await instance.get<{ host: string; url: string }>(
`http://pinned-fastgpt.test:${targetPort}/ok`,
{
httpAgent: maliciousAgent
}
);
expect(response.data).toEqual({
host: `pinned-fastgpt.test:${targetPort}`,
url: '/ok'
});
} finally {
await closeServer(targetServer);
}
});
it('应该拒绝重定向下一跳中的 DNS rebinding', async () => {
clearProxyEnv();
mutableServiceEnv.CHECK_INTERNAL_IP = false;
const carrierHost = getReachablePrivateHost();
if (!carrierHost) {
return;
}
vi.spyOn(dns, 'resolve4').mockResolvedValue(['8.8.8.8']);
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records'));
vi.spyOn(dns, 'lookup').mockImplementation(
async () => [{ address: '127.0.0.1', family: 4 }] as any
);
const redirectServer = await listen((req, res) => {
res.statusCode = 302;
res.setHeader('Location', 'http://redirect-rebind-fastgpt.test/secret');
res.end('redirect');
}, '0.0.0.0');
const redirectPort = getServerPort(redirectServer);
try {
const instance = createProxyAxios();
await expect(instance.get(`http://${carrierHost}:${redirectPort}/fetch`)).rejects.toThrow(
PRIVATE_URL_TEXT
);
} finally {
await closeServer(redirectServer);
}
});
it('走代理时命中 isInternalAddress 应该直接拒绝', async () => {
process.env.HTTP_PROXY = 'http://proxy.example:8080';
mutableServiceEnv.CHECK_INTERNAL_IP = false;
const adapter = vi.fn().mockResolvedValue({
data: {},
status: 200,
statusText: 'OK',
headers: {},
config: {}
});
const instance = createProxyAxios({ adapter });
await expect(instance.get('http://127.0.0.1/admin')).rejects.toThrow(PRIVATE_URL_TEXT);
expect(adapter).not.toHaveBeenCalled();
});
it('走代理且 isInternalAddress 返回 false 时应保留代理路径并跳过 pinning lookup', async () => {
process.env.HTTP_PROXY = 'http://proxy.example:8080';
mutableServiceEnv.CHECK_INTERNAL_IP = false;
vi.spyOn(dns, 'resolve4').mockRejectedValue(new Error('DNS unavailable'));
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('DNS unavailable'));
const lookupSpy = vi.spyOn(dns, 'lookup');
const adapter = vi.fn().mockResolvedValue({
data: { ok: true },
status: 200,
statusText: 'OK',
headers: {},
config: {}
});
const instance = createProxyAxios({ adapter });
const response = await instance.get('http://proxy-only-fastgpt.test/resource');
expect(response.data).toEqual({ ok: true });
expect(adapter).toHaveBeenCalled();
expect(lookupSpy).not.toHaveBeenCalled();
});
it('直连路径 DNS lookup 失败时应该失败', async () => {
clearProxyEnv();
mutableServiceEnv.CHECK_INTERNAL_IP = false;
vi.spyOn(dns, 'resolve4').mockResolvedValue(['8.8.8.8']);
vi.spyOn(dns, 'resolve6').mockRejectedValue(new Error('No AAAA records'));
vi.spyOn(dns, 'lookup').mockImplementation(async () => {
throw new Error('DNS lookup failed');
});
const adapter = vi.fn().mockResolvedValue({
data: { ok: true },
status: 200,
statusText: 'OK',
headers: {},
config: {}
});
const instance = createProxyAxios({ adapter });
await expect(instance.get('http://lookup-fail-fastgpt.test/resource')).rejects.toThrow(
'DNS lookup failed'
);
expect(adapter).not.toHaveBeenCalled();
});
});
describe('pickOutboundAxios', () => { describe('pickOutboundAxios', () => {
it.each([ it.each([
'http://example.com', 'http://example.com',
......
...@@ -3,7 +3,6 @@ import { ...@@ -3,7 +3,6 @@ import {
getWorkflowHttpNodeHttpsAgentConfig, getWorkflowHttpNodeHttpsAgentConfig,
replaceJsonBodyString replaceJsonBodyString
} from '@fastgpt/service/core/workflow/dispatch/tools/http468'; } from '@fastgpt/service/core/workflow/dispatch/tools/http468';
import { httpsCertificateIgnoreAgent } from '@fastgpt/service/common/api/axios';
import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type'; import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/type';
describe('replaceJsonBodyString', () => { describe('replaceJsonBodyString', () => {
...@@ -652,7 +651,7 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => { ...@@ -652,7 +651,7 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => {
global.systemEnv = originalSystemEnv; global.systemEnv = originalSystemEnv;
}); });
it('should not inject httpsAgent when ignoreHttpsCertificate is disabled', () => { it('should not inject safe axios TLS option when ignoreHttpsCertificate is disabled', () => {
global.systemEnv = { global.systemEnv = {
...originalSystemEnv, ...originalSystemEnv,
workflowHttpNode: { workflowHttpNode: {
...@@ -663,7 +662,7 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => { ...@@ -663,7 +662,7 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => {
expect(getWorkflowHttpNodeHttpsAgentConfig('https://example.com')).toEqual({}); expect(getWorkflowHttpNodeHttpsAgentConfig('https://example.com')).toEqual({});
}); });
it('should inject httpsAgent only for HTTPS requests when enabled', () => { it('should inject safe axios TLS option only for HTTPS requests when enabled', () => {
global.systemEnv = { global.systemEnv = {
...originalSystemEnv, ...originalSystemEnv,
workflowHttpNode: { workflowHttpNode: {
...@@ -672,7 +671,8 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => { ...@@ -672,7 +671,8 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => {
}; };
const httpsConfig = getWorkflowHttpNodeHttpsAgentConfig('https://example.com'); const httpsConfig = getWorkflowHttpNodeHttpsAgentConfig('https://example.com');
expect(httpsConfig.httpsAgent).toBe(httpsCertificateIgnoreAgent); expect(httpsConfig.__safeAxios).toEqual({ rejectUnauthorized: false });
expect(httpsConfig).not.toHaveProperty('httpsAgent');
expect(httpsConfig).not.toHaveProperty('proxy'); expect(httpsConfig).not.toHaveProperty('proxy');
expect(getWorkflowHttpNodeHttpsAgentConfig('http://example.com')).toEqual({}); expect(getWorkflowHttpNodeHttpsAgentConfig('http://example.com')).toEqual({});
}); });
......
...@@ -30,16 +30,16 @@ ...@@ -30,16 +30,16 @@
"qpm_tips": "Maximum number of queries per minute per IP", "qpm_tips": "Maximum number of queries per minute per IP",
"request_address": "Request URL", "request_address": "Request URL",
"show_node": "real-time running status", "show_node": "real-time running status",
"show_share_link_modal_title": "Get Started",
"show_skill_reference": "Show skill references", "show_skill_reference": "Show skill references",
"show_skill_reference_tips": "View all referenced Skills and their loading process", "show_skill_reference_tips": "View all referenced Skills and their loading process",
"show_share_link_modal_title": "Get Started",
"token_auth": "Token Authentication", "token_auth": "Token Authentication",
"token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.", "token_auth_tips": "Token authentication server URL. If provided, a request will be sent to the specified server for authentication before each conversation.",
"token_auth_use_cases": "View Token Authentication Guide", "token_auth_use_cases": "View Token Authentication Guide",
"use_default_domain": "Use Default Domain", "use_default_domain": "Use Default Domain",
"wechat.bot": "WeChat Personal", "wechat.bot": "WeChat Personal",
"wechat.bot_desc": "Connect to WeChat personal chat via ClawBot", "wechat.bot_desc": "Connect to WeChat personal chat via ClawBot",
"wechat.confirmed_tip": "Login successful!", "wechat.confirmed_tip": "Login successful",
"wechat.create": "Create WeChat Channel", "wechat.create": "Create WeChat Channel",
"wechat.edit": "Edit WeChat Channel", "wechat.edit": "Edit WeChat Channel",
"wechat.expired_tip": "QR code expired", "wechat.expired_tip": "QR code expired",
......
...@@ -30,16 +30,16 @@ ...@@ -30,16 +30,16 @@
"qpm_tips": "每个 IP 每分钟最多提问多少次", "qpm_tips": "每个 IP 每分钟最多提问多少次",
"request_address": "请求地址", "request_address": "请求地址",
"show_node": "实时运行状态", "show_node": "实时运行状态",
"show_share_link_modal_title": "开始使用",
"show_skill_reference": "查看技能引用", "show_skill_reference": "查看技能引用",
"show_skill_reference_tips": "查看引用的所有技能及加载过程", "show_skill_reference_tips": "查看引用的所有技能及加载过程",
"show_share_link_modal_title": "开始使用",
"token_auth": "身份验证", "token_auth": "身份验证",
"token_auth_tips": "身份校验服务器地址", "token_auth_tips": "身份校验服务器地址",
"token_auth_use_cases": "查看身份验证使用说明", "token_auth_use_cases": "查看身份验证使用说明",
"use_default_domain": "使用默认域名", "use_default_domain": "使用默认域名",
"wechat.bot": "微信个人号", "wechat.bot": "微信个人号",
"wechat.bot_desc": "通过 ClawBot 接入微信个人号聊天", "wechat.bot_desc": "通过 ClawBot 接入微信个人号聊天",
"wechat.confirmed_tip": "登录成功", "wechat.confirmed_tip": "登录成功",
"wechat.create": "创建微信渠道", "wechat.create": "创建微信渠道",
"wechat.edit": "编辑微信渠道", "wechat.edit": "编辑微信渠道",
"wechat.expired_tip": "二维码已过期", "wechat.expired_tip": "二维码已过期",
......
...@@ -30,16 +30,16 @@ ...@@ -30,16 +30,16 @@
"qpm_tips": "每個 IP 每分鐘最高查詢次數", "qpm_tips": "每個 IP 每分鐘最高查詢次數",
"request_address": "請求網址", "request_address": "請求網址",
"show_node": "即時執行狀態", "show_node": "即時執行狀態",
"show_share_link_modal_title": "開始使用",
"show_skill_reference": "查看技能引用", "show_skill_reference": "查看技能引用",
"show_skill_reference_tips": "查看引用的所有技能及載入過程", "show_skill_reference_tips": "查看引用的所有技能及載入過程",
"show_share_link_modal_title": "開始使用",
"token_auth": "身分驗證", "token_auth": "身分驗證",
"token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。", "token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。",
"token_auth_use_cases": "檢視身分驗證指南", "token_auth_use_cases": "檢視身分驗證指南",
"use_default_domain": "使用預設域名", "use_default_domain": "使用預設域名",
"wechat.bot": "微信個人號", "wechat.bot": "微信個人號",
"wechat.bot_desc": "透過 ClawBot 連結微信個人號聊天", "wechat.bot_desc": "透過 ClawBot 連結微信個人號聊天",
"wechat.confirmed_tip": "登入成功", "wechat.confirmed_tip": "登入成功",
"wechat.create": "建立微信頻道", "wechat.create": "建立微信頻道",
"wechat.edit": "編輯微信頻道", "wechat.edit": "編輯微信頻道",
"wechat.expired_tip": "QR 碼已過期", "wechat.expired_tip": "QR 碼已過期",
......
Subproject commit 32ff3fa6772fa67463ad64a080a568e620433e8f Subproject commit 26ea071c930f2638203ba8049b01d0cbd20f51e0
{ {
"name": "@fastgpt/app", "name": "@fastgpt/app",
"version": "4.15.1", "version": "4.15.2",
"private": false, "private": false,
"browserslist": [ "browserslist": [
"Chrome >= 80", "Chrome >= 80",
......
...@@ -167,7 +167,9 @@ const QRLoginModal = ({ ...@@ -167,7 +167,9 @@ const QRLoginModal = ({
case 'scanned': case 'scanned':
return ( return (
<Flex direction="column" align="center" justify="center" minH="350px"> <Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px">👀</Text> <Text fontSize="60px" h={'60px'}>
👀
</Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="blue.600"> <Text mt={4} fontSize="lg" fontWeight="medium" color="blue.600">
{t('publish:wechat.scanned_tip')} {t('publish:wechat.scanned_tip')}
</Text> </Text>
...@@ -179,16 +181,20 @@ const QRLoginModal = ({ ...@@ -179,16 +181,20 @@ const QRLoginModal = ({
case 'confirmed': case 'confirmed':
return ( return (
<Flex direction="column" align="center" justify="center" minH="350px"> <Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text> <Box fontSize="60px" h={'60px'}>
<Text mt={4} fontSize="lg" fontWeight="medium" color="green.600">
</Box>
<Box mt={4} fontSize="lg" fontWeight="medium" color="green.600">
{t('publish:wechat.confirmed_tip')} {t('publish:wechat.confirmed_tip')}
</Text> </Box>
</Flex> </Flex>
); );
case 'expired': case 'expired':
return ( return (
<Flex direction="column" align="center" justify="center" minH="350px"> <Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text> <Text fontSize="60px" h={'60px'}>
</Text>
<Text mt={4} fontSize="lg" fontWeight="medium" color="orange.600"> <Text mt={4} fontSize="lg" fontWeight="medium" color="orange.600">
{t('publish:wechat.expired_tip')} {t('publish:wechat.expired_tip')}
</Text> </Text>
...@@ -200,7 +206,9 @@ const QRLoginModal = ({ ...@@ -200,7 +206,9 @@ const QRLoginModal = ({
case 'error': case 'error':
return ( return (
<Flex direction="column" align="center" justify="center" minH="350px"> <Flex direction="column" align="center" justify="center" minH="350px">
<Text fontSize="60px"></Text> <Text fontSize="60px" h={'60px'}>
</Text>
<Text mt={4} color="red.500"> <Text mt={4} color="red.500">
{errMsg} {errMsg}
</Text> </Text>
...@@ -214,12 +222,7 @@ const QRLoginModal = ({ ...@@ -214,12 +222,7 @@ const QRLoginModal = ({
return ( return (
<MyModal isOpen onClose={onClose} title={t('publish:wechat.login_title')} size="md"> <MyModal isOpen onClose={onClose} title={t('publish:wechat.login_title')} size="md">
<ModalBody py={6}>{renderContent()}</ModalBody> {renderContent()}
<ModalFooter>
<Button variant="whiteBase" onClick={onClose}>
{t('common:Close')}
</Button>
</ModalFooter>
</MyModal> </MyModal>
); );
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment