diff --git a/document/content/self-host/upgrading/4-14/41428.en.mdx b/document/content/self-host/upgrading/4-14/41428.en.mdx new file mode 100644 index 000000000000..3669076b81a8 --- /dev/null +++ b/document/content/self-host/upgrading/4-14/41428.en.mdx @@ -0,0 +1,15 @@ +--- +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. diff --git a/document/content/self-host/upgrading/4-14/41428.mdx b/document/content/self-host/upgrading/4-14/41428.mdx new file mode 100644 index 000000000000..aadaf1464017 --- /dev/null +++ b/document/content/self-host/upgrading/4-14/41428.mdx @@ -0,0 +1,15 @@ +--- +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 版本不匹配导致服务异常。 diff --git a/document/content/self-host/upgrading/4-14/41429.en.mdx b/document/content/self-host/upgrading/4-14/41429.en.mdx new file mode 100644 index 000000000000..faee9bbeb983 --- /dev/null +++ b/document/content/self-host/upgrading/4-14/41429.en.mdx @@ -0,0 +1,16 @@ +--- +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. diff --git a/document/content/self-host/upgrading/4-14/41429.mdx b/document/content/self-host/upgrading/4-14/41429.mdx new file mode 100644 index 000000000000..427eb7f52a4a --- /dev/null +++ b/document/content/self-host/upgrading/4-14/41429.mdx @@ -0,0 +1,16 @@ +--- +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。 diff --git a/document/content/self-host/upgrading/4-14/meta.en.json b/document/content/self-host/upgrading/4-14/meta.en.json index 6b077ea29493..bd50558f2df8 100644 --- a/document/content/self-host/upgrading/4-14/meta.en.json +++ b/document/content/self-host/upgrading/4-14/meta.en.json @@ -2,6 +2,8 @@ "title": "4.14.x", "description": "", "pages": [ + "41429", + "41428", "41427", "41426", "41425", diff --git a/document/content/self-host/upgrading/4-14/meta.json b/document/content/self-host/upgrading/4-14/meta.json index 6b077ea29493..bd50558f2df8 100644 --- a/document/content/self-host/upgrading/4-14/meta.json +++ b/document/content/self-host/upgrading/4-14/meta.json @@ -2,6 +2,8 @@ "title": "4.14.x", "description": "", "pages": [ + "41429", + "41428", "41427", "41426", "41425", diff --git a/document/content/self-host/upgrading/4-15/4151.en.mdx b/document/content/self-host/upgrading/4-15/4151.en.mdx index 20bbcc241071..c85e02f34da6 100644 --- a/document/content/self-host/upgrading/4-15/4151.en.mdx +++ b/document/content/self-host/upgrading/4-15/4151.en.mdx @@ -1,5 +1,5 @@ --- -title: 'V4.15.1 (In Progress)' +title: 'V4.15.1' description: 'FastGPT V4.15.1 Release Notes' --- diff --git a/document/content/self-host/upgrading/4-15/4151.mdx b/document/content/self-host/upgrading/4-15/4151.mdx index d379ed193799..e54a9849f25d 100644 --- a/document/content/self-host/upgrading/4-15/4151.mdx +++ b/document/content/self-host/upgrading/4-15/4151.mdx @@ -1,5 +1,5 @@ --- -title: 'V4.15.1(进行中)' +title: 'V4.15.1' description: 'FastGPT V4.15.1 更新说明' --- diff --git a/document/content/self-host/upgrading/4-15/4152.mdx b/document/content/self-host/upgrading/4-15/4152.mdx new file mode 100644 index 000000000000..c463377c0e05 --- /dev/null +++ b/document/content/self-host/upgrading/4-15/4152.mdx @@ -0,0 +1,15 @@ +--- +title: 'V4.15.2(进行中)' +description: 'FastGPT V4.15.2 更新说明' +--- + +## 🚀 新增内容 + +## ⚙️ 优化 + +1. Skill 未关联应用时的删除弹窗文案。 +2. 适配最新微信发布渠道 sdk。 + +## 🐛 修复 + +## 🛠️ 代码优化 diff --git a/document/content/self-host/upgrading/4-15/meta.en.json b/document/content/self-host/upgrading/4-15/meta.en.json index e0ab11ab9dee..2a4e26e12153 100644 --- a/document/content/self-host/upgrading/4-15/meta.en.json +++ b/document/content/self-host/upgrading/4-15/meta.en.json @@ -1,5 +1,5 @@ { "title": "4.15.x", "description": "", - "pages": ["4151", "41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"] + "pages": ["4151", "4152", "41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"] } diff --git a/document/content/self-host/upgrading/4-15/meta.json b/document/content/self-host/upgrading/4-15/meta.json index 2e89c532fc9c..2a4e26e12153 100644 --- a/document/content/self-host/upgrading/4-15/meta.json +++ b/document/content/self-host/upgrading/4-15/meta.json @@ -1,5 +1,5 @@ { "title": "4.15.x", "description": "", - "pages": ["4151","41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"] + "pages": ["4151", "4152", "41500", "41507", "41506", "41505", "41504", "41503", "41502", "41501"] } diff --git a/document/content/toc.en.mdx b/document/content/toc.en.mdx index 8bf31af5514f..47420a79847e 100644 --- a/document/content/toc.en.mdx +++ b/document/content/toc.en.mdx @@ -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/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/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/4144](/en/self-host/upgrading/4-14/4144) - [/en/self-host/upgrading/4-14/4145](/en/self-host/upgrading/4-14/4145) diff --git a/document/content/toc.mdx b/document/content/toc.mdx index e75fe35944da..0b78f0a86536 100644 --- a/document/content/toc.mdx +++ b/document/content/toc.mdx @@ -144,6 +144,8 @@ description: FastGPT 文档目录 - [/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/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/4144](/self-host/upgrading/4-14/4144) - [/self-host/upgrading/4-14/4145](/self-host/upgrading/4-14/4145) @@ -162,6 +164,7 @@ description: FastGPT 文档目录 - [/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/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/41](/self-host/upgrading/outdated/41) - [/self-host/upgrading/outdated/4100](/self-host/upgrading/outdated/4100) diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index d2250d950d57..2cd755d2e3d8 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -203,8 +203,8 @@ "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.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.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-04T00:03:29+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/design/dataset.en.mdx": "2026-04-26T21:08:47+08:00", @@ -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/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/4151.en.mdx": "2026-07-03T22:44:25+08:00", - "content/self-host/upgrading/4-15/4151.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-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.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", diff --git a/packages/service/common/api/axios.ts b/packages/service/common/api/axios.ts index e52433c558d1..2212af0665f2 100644 --- a/packages/service/common/api/axios.ts +++ b/packages/service/common/api/axios.ts @@ -4,9 +4,14 @@ import _, { type AxiosResponse, type InternalAxiosRequestConfig } 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 { getProxyForUrl } from 'proxy-from-env'; 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 { SERVICE_LOCAL_HOST } from '../system/tools'; @@ -21,32 +26,27 @@ import { SERVICE_LOCAL_HOST } from '../system/tools'; const addSSRFInterceptor = (instance: AxiosInstance) => { instance.interceptors.request.use(async (config): Promise => { const safeConfig = config as SafeRedirectInternalConfig; - const requestUrl = buildRequestUrl(safeConfig); - if (!requestUrl) return config; - - if (await isInternalAddress(requestUrl)) { - return Promise.reject(new Error(PRIVATE_URL_TEXT)); - } + const preparedConfig = await prepareSafeRequestConfig(safeConfig); const maxRedirects = - safeConfig.__safeRedirect?.maxRedirects ?? - (typeof safeConfig.maxRedirects === 'number' - ? safeConfig.maxRedirects + preparedConfig.__safeRedirect?.maxRedirects ?? + (typeof preparedConfig.maxRedirects === 'number' + ? preparedConfig.maxRedirects : SAFE_AXIOS_MAX_REDIRECTS); - const nextConfig: SafeRedirectInternalConfig = { - ...safeConfig, + const nextConfigWithRedirect: SafeRedirectInternalConfig = { + ...preparedConfig, // 禁用底层自动跳转,保留调用方 maxRedirects 语义给手动跳转状态使用。 maxRedirects: 0, - validateStatus: getRedirectValidateStatus(safeConfig.validateStatus, maxRedirects), - __safeRedirect: safeConfig.__safeRedirect ?? { + validateStatus: getRedirectValidateStatus(preparedConfig.validateStatus, maxRedirects), + __safeRedirect: preparedConfig.__safeRedirect ?? { count: 0, maxRedirects, - validateStatus: safeConfig.validateStatus + validateStatus: preparedConfig.validateStatus } }; - return nextConfig; + return nextConfigWithRedirect; }); instance.interceptors.response.use(async (response) => { @@ -89,6 +89,14 @@ const createProxyAgent = (options?: ProxyAgentOptions) => new ProxyAgent(options const SAFE_AXIOS_MAX_REDIRECTS = 5; 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 = { type SafeRedirectConfig = AxiosRequestConfig & { __safeRedirect?: SafeRedirectState; + __safeAxios?: SafeAxiosRequestOptions; }; type SafeRedirectInternalConfig = InternalAxiosRequestConfig & { @@ -112,8 +121,137 @@ type SafeRedirectInternalConfig = InternalAxiosRequestConfig & { maxRedirects: number; 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 => { + 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 => { + 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 => { + 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 => REDIRECT_STATUS_CODES.has(response.status) && typeof response.headers.location === 'string'; @@ -247,8 +385,10 @@ const getRedirectValidateStatus = ( }; /** - * 工作流 HTTP 节点跳过 HTTPS 证书校验专用 agent。 - * 仍复用 ProxyAgent,只调整目标站 TLS 校验策略,避免改变部署环境的代理语义。 + * 兼容旧调用方的跳过 HTTPS 证书校验专用 agent。 + * + * safe axios 的直连路径会覆盖调用方 agent;新代码需要通过 `__safeAxios` + * 传递受控 TLS 选项,避免自定义 agent 绕过 DNS pinning。 */ export const httpsCertificateIgnoreAgent = createProxyAgent({ rejectUnauthorized: false diff --git a/packages/service/common/middle/methodGuard.ts b/packages/service/common/middle/methodGuard.ts new file mode 100644 index 000000000000..74606dfeb3f1 --- /dev/null +++ b/packages/service/common/middle/methodGuard.ts @@ -0,0 +1,27 @@ +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` + }); + }; +} diff --git a/packages/service/common/system/utils.ts b/packages/service/common/system/utils.ts index db19a58bd56d..fea66c68f43c 100644 --- a/packages/service/common/system/utils.ts +++ b/packages/service/common/system/utils.ts @@ -4,11 +4,11 @@ import { } from '@fastgpt/global/common/system/network'; import { serviceEnv } from '../../env'; -const { isInternalAddress } = createInternalAddressChecker({ +const { isInternalAddress, isInternalResolvedIP } = createInternalAddressChecker({ checkInternalIp: () => serviceEnv.CHECK_INTERNAL_IP }); -export { isInternalAddress, PRIVATE_URL_TEXT }; +export { isInternalAddress, isInternalResolvedIP, PRIVATE_URL_TEXT }; /** * 用于"保存配置 URL"或"调用前校验"的统一安全检查: diff --git a/packages/service/core/workflow/dispatch/tools/http468.ts b/packages/service/core/workflow/dispatch/tools/http468.ts index e3dad07e4bf5..cd2570ccdde7 100644 --- a/packages/service/core/workflow/dispatch/tools/http468.ts +++ b/packages/service/core/workflow/dispatch/tools/http468.ts @@ -27,7 +27,7 @@ import { getLogger, LogCategories } from '../../../../common/logger'; import { formatHttpError, getNodeErrResponse } from '../utils'; import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils'; 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 { checkStrOversize, logOversizeString } from '../../../../common/string/replaceVariable'; import { getWorkflowAppId } from '../utils/source'; @@ -40,7 +40,7 @@ const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS); */ export const getWorkflowHttpNodeHttpsAgentConfig = ( url: string -): Pick => { +): Pick => { const ignoreHttpsCertificate = global.systemEnv?.workflowHttpNode?.ignoreHttpsCertificate === true; @@ -57,7 +57,9 @@ export const getWorkflowHttpNodeHttpsAgentConfig = ( } return { - httpsAgent: httpsCertificateIgnoreAgent + __safeAxios: { + rejectUnauthorized: false + } }; }; diff --git a/packages/service/test/common/api/axios.test.ts b/packages/service/test/common/api/axios.test.ts index f075ddcbad09..21f5fbd3af38 100644 --- a/packages/service/test/common/api/axios.test.ts +++ b/packages/service/test/common/api/axios.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'; import http from 'http'; import os from 'os'; +import dns from 'dns/promises'; import { createProxyAxios } from '../../../common/api/axios'; import { PRIVATE_URL_TEXT } from '../../../common/system/utils'; import { serviceEnv } from '../../../env'; @@ -17,11 +18,47 @@ type AxiosRequestConfig = { describe('axios.ts', () => { const mutableServiceEnv = serviceEnv as { CHECK_INTERNAL_IP: boolean }; 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(() => { 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', () => { beforeEach(() => { vi.clearAllMocks(); @@ -130,6 +167,11 @@ describe('axios.ts', () => { }); 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({ data: { ok: true }, status: 200, @@ -306,6 +348,204 @@ describe('axios.ts', () => { }); }); + describe('safe axios DNS pinning', () => { + const listen = (handler: http.RequestListener, host = '127.0.0.1') => + new Promise((resolve) => { + const server = http.createServer(handler); + server.listen(0, host, () => resolve(server)); + }); + + const closeServer = (server: http.Server) => + new Promise((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', () => { it.each([ 'http://example.com', diff --git a/packages/service/test/core/workflow/dispatch/tools/http.test.ts b/packages/service/test/core/workflow/dispatch/tools/http.test.ts index 19a4101e824b..14c3480462e1 100644 --- a/packages/service/test/core/workflow/dispatch/tools/http.test.ts +++ b/packages/service/test/core/workflow/dispatch/tools/http.test.ts @@ -3,7 +3,6 @@ import { getWorkflowHttpNodeHttpsAgentConfig, replaceJsonBodyString } 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'; describe('replaceJsonBodyString', () => { @@ -652,7 +651,7 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => { 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 = { ...originalSystemEnv, workflowHttpNode: { @@ -663,7 +662,7 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => { 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 = { ...originalSystemEnv, workflowHttpNode: { @@ -672,7 +671,8 @@ describe('getWorkflowHttpNodeHttpsAgentConfig', () => { }; 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(getWorkflowHttpNodeHttpsAgentConfig('http://example.com')).toEqual({}); }); diff --git a/packages/web/i18n/en/publish.json b/packages/web/i18n/en/publish.json index 5424f9274a26..c3454053aa6c 100644 --- a/packages/web/i18n/en/publish.json +++ b/packages/web/i18n/en/publish.json @@ -30,16 +30,16 @@ "qpm_tips": "Maximum number of queries per minute per IP", "request_address": "Request URL", "show_node": "real-time running status", + "show_share_link_modal_title": "Get Started", "show_skill_reference": "Show skill references", "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_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", "use_default_domain": "Use Default Domain", "wechat.bot": "WeChat Personal", "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.edit": "Edit WeChat Channel", "wechat.expired_tip": "QR code expired", diff --git a/packages/web/i18n/zh-CN/publish.json b/packages/web/i18n/zh-CN/publish.json index 091cc1d60188..753a9cd6f9e4 100644 --- a/packages/web/i18n/zh-CN/publish.json +++ b/packages/web/i18n/zh-CN/publish.json @@ -30,16 +30,16 @@ "qpm_tips": "每个 IP 每分钟最多提问多少次", "request_address": "请求地址", "show_node": "实时运行状态", + "show_share_link_modal_title": "开始使用", "show_skill_reference": "查看技能引用", "show_skill_reference_tips": "查看引用的所有技能及加载过程", - "show_share_link_modal_title": "开始使用", "token_auth": "身份验证", "token_auth_tips": "身份校验服务器地址", "token_auth_use_cases": "查看身份验证使用说明", "use_default_domain": "使用默认域名", "wechat.bot": "微信个人号", "wechat.bot_desc": "通过 ClawBot 接入微信个人号聊天", - "wechat.confirmed_tip": "登录成功!", + "wechat.confirmed_tip": "登录成功", "wechat.create": "创建微信渠道", "wechat.edit": "编辑微信渠道", "wechat.expired_tip": "二维码已过期", diff --git a/packages/web/i18n/zh-Hant/publish.json b/packages/web/i18n/zh-Hant/publish.json index 8cd6bca38219..39e7785f34bb 100644 --- a/packages/web/i18n/zh-Hant/publish.json +++ b/packages/web/i18n/zh-Hant/publish.json @@ -30,16 +30,16 @@ "qpm_tips": "每個 IP 每分鐘最高查詢次數", "request_address": "請求網址", "show_node": "即時執行狀態", + "show_share_link_modal_title": "開始使用", "show_skill_reference": "查看技能引用", "show_skill_reference_tips": "查看引用的所有技能及載入過程", - "show_share_link_modal_title": "開始使用", "token_auth": "身分驗證", "token_auth_tips": "身分驗證伺服器網址。若有提供,每次對話前將向指定伺服器傳送驗證請求。", "token_auth_use_cases": "檢視身分驗證指南", "use_default_domain": "使用預設域名", "wechat.bot": "微信個人號", "wechat.bot_desc": "透過 ClawBot 連結微信個人號聊天", - "wechat.confirmed_tip": "登入成功!", + "wechat.confirmed_tip": "登入成功", "wechat.create": "建立微信頻道", "wechat.edit": "編輯微信頻道", "wechat.expired_tip": "QR 碼已過期", diff --git a/pro b/pro index 32ff3fa6772f..26ea071c930f 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 32ff3fa6772fa67463ad64a080a568e620433e8f +Subproject commit 26ea071c930f2638203ba8049b01d0cbd20f51e0 diff --git a/projects/app/package.json b/projects/app/package.json index d147931bbac5..28b32602d0a8 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -1,6 +1,6 @@ { "name": "@fastgpt/app", - "version": "4.15.1", + "version": "4.15.2", "private": false, "browserslist": [ "Chrome >= 80", diff --git a/projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx b/projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx index aa6e0be1c735..f9d8c2802a06 100644 --- a/projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx +++ b/projects/app/src/pageComponents/app/detail/Publish/Wechat/QRLoginModal.tsx @@ -167,7 +167,9 @@ const QRLoginModal = ({ case 'scanned': return ( - 👀 + + 👀 + {t('publish:wechat.scanned_tip')} @@ -179,16 +181,20 @@ const QRLoginModal = ({ case 'confirmed': return ( - - + + ✅ + + {t('publish:wechat.confirmed_tip')} - + ); case 'expired': return ( - + + ⏰ + {t('publish:wechat.expired_tip')} @@ -200,7 +206,9 @@ const QRLoginModal = ({ case 'error': return ( - + + ❌ + {errMsg} @@ -214,12 +222,7 @@ const QRLoginModal = ({ return ( - {renderContent()} - - - + {renderContent()} ); };