












环境:Ubuntu 22.04 / Docker / lobehub/lobehub:v2.2.13(2026-08-01 发布)
目标版本:v2.2.9(修复前)vs v2.2.13(最新稳定)vs v2.2.14-canary.74(8 月 12 日最新)
2026 年 7 月 2 日,LobeChat 一口气公开了四个漏洞:CVE-2026-59095(SSRF,7.7 分)、CVE-2026-58580(BOLA,5.9 分)、CVE-2026-58578(ReDoS,6.5 分)、CVE-2026-59098(RAG 访问控制,6.5 分)。官方在同一天把四个修复 commit 合进了 canary 分支。
我把四个修复的 diff 全拉下来逐行看了一遍,注意到一个细节:SSRF 那个修复只覆盖了两个端点——agentSkills.importFromUrl 和生成图片时的 fetchImageFromUrl。但 LobeChat 的 bot 平台功能(Discord/Slack/微信/飞书机器人)里,有一堆地方会拿用户给的 URL 去发请求,官方一个都没碰。
这篇文章讲我怎么顺着这个线索找到第五个洞:botMessage 接口的附件下载路径,从 v2.2.9 到 8 月 12 日发布的 v2.2.14-canary.74,一直都是裸 fetch,没有任何 SSRF 防护。我在最新版上部署验证,伪造凭据创建 bot,成功让服务器主动请求了宿主机内网地址和腾讯云 metadata 服务。
文章的结构是:先讲四个已公开漏洞和官方的修法(另外三个修复我也逐个验证了是否完整),再讲 ssrfSafeFetch 防护机制本身,然后引出 botMessage 附件下载这条没人管的路径,接着是 OSS 版权限为什么拦不住普通用户,然后完整实战验证,最后总结成"修复完整性审计"这个方法论,附一个自动化审计工具。
核心发现:官方修了 6 处同类型端点,漏了 4 处,漏掉的 4 处还是同一个入口(botMessage),最新版依然能打。
漏洞本身不复杂:两个 tRPC 端点会让服务器去 fetch 用户提供的 URL。NVD 对它的描述是 "allows authenticated attackers to direct internal HTTP requests to arbitrary URLs by supplying user-controlled... "——认证用户就能让服务器发内网请求。这个洞最初是以私有 advisory 提交的(GHSA-53h9-fmjf-frwr),7 月 2 日随修复一起公开。
第一个是 agentSkills.importFromUrl,用来从任意 URL 导入技能包。修复前的代码长这样:
// 修复前:直接 fetch 用户给的 URL
response = await fetch(input.url, { signal: controller.signal });
第二个是生成图片时对图片来源 URL 的抓取(fetchImageFromUrl),同一个问题。
官方的修复是给这两个端点套上项目里现成的 ssrfSafeFetch 封装:
// apps/server/src/services/skill/importer.ts(修复后)
response = await ssrfSafeFetch(input.url, { signal: controller.signal });
// apps/server/src/services/generation/index.ts(修复后)
const response = await ssrfSafeFetch(url, { headers: fetchHeaders });
PR 编号 #16601,7 月 2 日合并。我特意看了这个 PR 的 diff:改了 5 个文件,涉及 skill 导入、图片生成、web-crawler 的 naive 实现,改动量不大。就这些。
这是 MessageModel 的问题:5 个写方法——updateMessagePlugin、updatePluginState、updatePluginError、updateTTS、updateTranslate——在数据库查询时只按行 id 过滤,没带 userId 谓词。
后果:在多用户的 server-database 部署里,登录用户拿别人的消息 id 就能改对方的插件状态、TTS 配置、翻译内容。比如 updateTranslate,v2.2.9 里是这样:
// v2.2.9:query 带 translatesOwnership,但 INSERT 路径没有
updateTranslate = async (id: string, translate: Partial<ChatTranslate>) => {
const result = await this.db.query.messageTranslates.findFirst({
where: and(eq(messageTranslates.id, id), this.translatesOwnership()),
});
// 如果不存在,直接插入——这里没有归属校验
if (!result) {
return this.db.insert(messageTranslates).values({
...translate,
id,
userId: this.userId,
workspaceId: this.workspaceId ?? null,
});
}
...
}
updateTranslate 的 INSERT 路径有个问题:id 是调用方传的,userId 却是当前用户。如果攻击者知道别人的 message id,往 messageTranslates 表插一行,把别人的消息 id 关联上自己的翻译内容——别人看到自己的消息翻译被篡改了。而 UPDATE 路径带 translatesOwnership() 只挡了已存在的行,挡不住"插入伪造关联"。
官方修复是在 router 层统一补了资源归属校验:
// canary 修复后:先校验消息归属
updateTTS: messageProcedure
.use(withScopedPermission('message:update'))
.mutation(async ({ input, ctx }) => {
await assertCanUseMessageTargets(guardCtx(ctx), [input.id]);
...
}),
我验证了这个修复的完整性:把 message router 里全部 23 个写方法过了一遍,每个都有对应的资源断言,没有漏网的。这个洞修得干净,只能当对照组。
GitHub 技能导入功能有个正则问题。https://github.com/owner/repo/tree/branch/<path>里的 path 段被直接拼进正则:
// 修复前:用户输入直接进正则
const basePathPattern = new RegExp(
`^[^/]+/${basePath.replaceAll(/^\/|\/$/g, '')}/SKILL\\.md$`,
);
basePath 来自https://github.com/owner/repo/tree/branch/<path>的 path 段,完全用户可控。构造(a+)+之类的模式就能让正则灾难性回溯,卡死 Node.js 事件循环;构造[invalid直接抛 SyntaxError。
basePath 拼接前经过了 replaceAll(/^/|/$/g, ''),只去掉了首尾斜杠。也就是说,攻击者传https://github.com/o/r/tree/b/(a+)+$这种,basePath 变成(a+)+$,正则就变成^[^/]+/(a+)+$/SKILL\.md$——(a+)+配合超长的输入字符串(GitHub zip 包里的路径)就会指数级回溯。
官方修法是把正则改成纯字符串匹配:
// 修复后:不用正则了
const normalizedBasePath = basePath.replaceAll(/^\/|\/$/g, '');
const suffix = `/${normalizedBasePath}/SKILL.md`;
const matchWithBasePath = allPaths.find((path) => path.endsWith(suffix));
这个修复我也确认过,是完整的。从修法看,就是把正则匹配换成了字符串匹配,能用字符串解决的就别用正则。
KnowledgeBaseSearchService 做语义搜索时,knowledgeBaseFiles 的查询只按 knowledgeBaseId 过滤,没带 workspace 谓词。攻击者拿别人的 knowledgeBaseId 就能解析出对方的 fileIds,再结合语义搜索接口把文件内容搜出来。
// 修复前:没带 workspace 过滤
const knowledgeFiles = await this.serverDB.query.knowledgeBaseFiles.findMany({
where: inArray(knowledgeBaseFiles.knowledgeBaseId, knowledgeIds),
});
// 修复后:加了 buildWorkspaceWhere
const knowledgeFiles = await this.serverDB.query.knowledgeBaseFiles.findMany({
where: and(
inArray(knowledgeBaseFiles.knowledgeBaseId, knowledgeIds),
buildWorkspaceWhere(
{ userId: this.userId, workspaceId: this.workspaceId },
knowledgeBaseFiles,
),
),
});
两条分支(vector 搜索和 BM25 搜索)我都跑了一遍源码,都带 workspace 过滤。这条也修干净了。
四个洞的修法各有各的套路:SSRF 是给调用点套防护,BOLA 是 router 层补资源断言,ReDoS 是正则改字符串匹配,RAG 是加 workspace 谓词。但有个共性:除了 SSRF,另外三个修复都覆盖了所有同类位置——BOLA 的 23 个写方法、ReDoS 的 findSkillMd、RAG 的两条搜索分支,我都逐个确认过,没有漏网的。
拿 BOLA 修复举例:assertCanUseMessageTargets 只在 workspace 模式下生效,个人模式(没有 workspaceId)直接 return。个人模式靠 model 层的 buildWorkspaceWhere 兜底——个人数据本来就是 userId + workspace_id IS NULL 过滤的,所以这条路是安全的。这个分层我没觉得有问题。
只有 SSRF 那个修复,覆盖范围明显偏小:两个端点,改完就完。当时我就想,是不是还有其他 fetch 用户 URL 的地方没被看到。这个念头直接引出后面的第五个洞。
在讲第五个洞之前,得先搞清楚官方的防护机制本身。ssrfSafeFetch 在 packages/ssrf-safe-fetch/index.ts,核心实现不长:
export const ssrfSafeFetch = async (
url: string,
options?: RequestInit,
ssrfOptions?: SSRFOptions,
): Promise<Response> => {
// 环境变量开关:SSRF_ALLOW_PRIVATE_IP_ADDRESS=1 会放行私网
const envAllowPrivate = process.env.SSRF_ALLOW_PRIVATE_IP_ADDRESS === '1';
const allowPrivate = ssrfOptions?.allowPrivateIPAddress ?? envAllowPrivate;
const agentOptions: RequestFilteringAgentOptions = {
allowIPAddressList: ssrfOptions?.allowIPAddressList ??
process.env.SSRF_ALLOW_IP_ADDRESS_LIST?.split(',').filter(Boolean) ?? [],
allowMetaIPAddress: allowPrivate,
allowPrivateIPAddress: allowPrivate,
denyIPAddressList: [],
};
// 用 request-filtering-agent 的 agent 拦截私网连接
const httpAgent = new RequestFilteringHttpAgent(agentOptions);
const httpsAgent = new RequestFilteringHttpsAgent(agentOptions);
const response = await fetch(url, {
...options,
agent: (parsedURL: URL) => (parsedURL.protocol === 'https:' ? httpsAgent : httpAgent),
} as any);
...
};
原理是 request-filtering-agent:在 TCP 连接建立前解析 DNS,如果目标 IP 落在私网段(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16 这些)就拒绝连接。而且 agent 是每个重定向跳都会调用的,所以 302 跳到内网也会被拦。
防护本身没毛病,问题在于:它只被用在官方修的那两个端点上。项目里 fetch 用户可控 URL 的地方远不止两个,而"没套 ssrfSafeFetch 的地方"就是裸奔。
这个封装的测试覆盖其实挺全的——私网 IP、云 metadata、file:// 协议、环境变量开关、响应体大小限制都有单测。防护能力是到位的,缺的是调用点覆盖。
另外,这个封装还支持通过环境变量放行特定 IP:
# 允许特定内网 IP(保持其他私网拦截)
SSRF_ALLOW_IP_ADDRESS_LIST=192.168.1.100,10.0.0.50
# 完全放行私网(不推荐,仅调试用)
SSRF_ALLOW_PRIVATE_IP_ADDRESS=1
这些都是合理的设计。但对 botMessage 的裸 fetch 来说,这些开关一个都管不到——它压根不走 ssrfSafeFetch。
我在全仓库搜裸 fetch 调用,结果 bot 平台四个 sendAttachments.ts 全是裸 fetch:
grep -n "fetch(attachment.fetchUrl" apps/server/src/services/bot/platforms/discord/sendAttachments.ts
# 33: const response = await fetch(attachment.fetchUrl, {
四个平台(Discord/Slack/微信/飞书)的 sendAttachments.ts 代码几乎一样:
// apps/server/src/services/bot/platforms/discord/sendAttachments.ts
const loadAttachmentBuffer = async (
attachment: BotMessageAttachment,
): Promise<Buffer | undefined> => {
if (attachment.data) {
// 优先 base64
return Buffer.from(attachment.data, 'base64');
}
if (attachment.fetchUrl) {
try {
// 这里!用户给的 URL 直接 fetch
const response = await fetch(attachment.fetchUrl, {
signal: AbortSignal.timeout(15_000),
});
if (response.ok) {
return Buffer.from(await response.arrayBuffer());
}
} catch (error) {
log('loadAttachmentBuffer: fetch failed for %s: %O', attachment.fetchUrl, error);
}
}
return undefined;
};
attachment.fetchUrl 从哪来?往上追,是 tRPC 接口 botMessage.sendMessage / sendDirectMessage 的输入:
// apps/server/src/routers/lambda/botMessage.ts
const attachmentsInputSchema = z.array(
z.object({
data: z.string().optional(),
fetchUrl: z.string().url().optional(),
mimeType: z.st此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。