
作者: Krithik Babu P (@DarkLycn1976)
发布时间: 2026-08-10
CVE: CVE-2026-19264
严重性: 严重 - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - 对受限目录中路径名的限制不当
受影响版本: gitroomhq/postiz-app < 2.22.1
修复版本: v2.22.1
Postiz 通过一条路由提供本地存储的媒体文件,该路由将 URL 提供的路径段拼接到上传目录上并将结果流式返回——既没有路径规范化,也没有包含性检查,更没有身份验证。
显而易见的路径遍历载荷会返回 404,因为 Next.js 在路由之前就压缩了 ../ 段。但经过 URL 编码的分隔符能够在路由匹配中存活,并且在通往文件系统调用的途中恰好再被解码一次,从而在所有检查的对侧恢复了路径遍历。
未经验证的攻击者可以读取应用程序进程可读的任何文件——包括保存有 JWT 签名密钥的自身环境。由于 Postiz 使用该密钥签署会话令牌,并且签发的令牌不包含过期声明,因此恢复该密钥可将文件读取原语转化为可永久伪造的、以任意用户(包括管理员)身份登录的会话。
一次未经验证的 GET 请求即可完全接管实例。
Postiz 是一个开源社交媒体排程平台——撰写本文时约有 34,000 个 GitHub Star——由 Next.js 前端和 NestJS 后端构建。它被众多代理机构和小型团队广泛自托管,用于管理已连接的社会媒体账户、排程内容和计费。
自托管部署可以将上传的媒体存储在本地,而非对象存储。该行为由单个环境变量控制:
STORAGE_PROVIDER=local
这是 .env.example 中内置的值,因此除非部署者特意配置 S3 或 Cloudflare R2,否则大多数自托管用户都会运行此配置。
当本地存储启用时,next.config.js 会将公共路径 /uploads/:path* 重写到内部 API 路由:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
在涉及任何漏洞之前,这条路由有两个特性使其值得关注:
[[...path]] 可选全捕获段意味着所有剩余的路径组件都会以数组形式到达,由处理器自行解释。当 STORAGE_PROVIDER 不是 local 时,重写指向 /404,处理器不可达。该配置门是部署与这个漏洞之间的唯一屏障。
v2.22.1 之前的处理器:
export const GET = async (request: NextRequest, context) => {
const { path } = await context.params;
const filePath =
process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
const response = createReadStream(filePath);
const fileStats = statSync(filePath);
// ... stream the file back to the caller
};
四行代码中存在三个缺陷:
path.normalize()、path.resolve()——都没有调用。传入的段被原样拼接。filePath 仍位于 UPLOAD_DIRECTORY 之内。+ '/' + 将组件视为文本,而非具有语义的路径。结果直接进入 createReadStream(),字节流以根据文件名推断出的 MIME 类型返回给调用者。没有扩展名白名单,也没有内容过滤器。
教科书式的攻击是:
GET /uploads/../../../etc/passwd
在 Postiz 上这会返回 404,而这个 404 正是这个漏洞能存活至今被发现的全部原因。
Next.js 在路由期间会规范化请求路径。原始的 ../ 段在路由器决定调用哪个处理器之前就被压缩了。当请求到达全捕获路由时,路径遍历已被消除——要么路径解析到某个没有匹配路由的位置,要么解析回 /uploads 内部且点号段已消失。
对于快速测试的人来说,这个 404 看起来像是*"框架已经处理了这个问题"*。它确实是一个真实有效的防御。问题不在于它不存在——而在于它在整个处理管线中的执行位置。
路由匹配和请求处理器执行的百分号解码次数不同。
如果分隔符经过百分号编码,那么该序列在路由匹配期间就不是路径分隔符。%2e%2e%2f 只是一个不透明字符串——规范化器没有理由去触碰的惰性文本。它完好无损地穿过路由,被全捕获段匹配,并在进入处理器的 params 途中被解码,重新变成 ../。
此时它被拼接到 UPLOAD_DIRECTORY 之后并交给 createReadStream()——已经越过路由、越过规范化、越过所有本可阻止它的控制。
可用的载荷形式:
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt → 200, 上传目录之外的文件
GET /uploads/..%2f..%2f..%2fetc%2fpasswd → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd → 200, 返回了真实的 /etc/passwd
双重编码无法生效——%252e 在单次解码过程中保持字面量,永远不会变成点号。恰好一层的编码才是最佳平衡点,这也有力地提醒人们:"编码得更狠一点"并不是一种策略。
需要记住的不变式:
在解码完成之前运行的控制,并不能保护最终的数据汇(sink)。
文件读取原语本身已是高危。它之所以达到严重级别,是因为它能触及的东西。
第 1 步——读取环境。 Node 进程自身的配置就位于部署根目录的磁盘上。.env 能提供以下信息(包括但不限于):
JWT_SECRET——会话令牌签名密钥DATABASE_URL——完整的 Postgres 凭据第 2 步——伪造会话。 Postiz 使用 jsonwebtoken 通过 HS256 以 JWT_SECRET 签署会话令牌。关键在于,令牌签发时不设置 expiresIn,因此伪造的令牌永久有效。
第 3 步——成为任何人。 身份验证中间件使用 id 声明从数据库重新解析用户。它特意不信任令牌中诸如 isSuperAdmin 之类的声明——这是良好的设计——但一旦你能签署任意的 id,这种加固就无关紧要了。签署 { id: <受害者用户 id> } 即可产生与合法登录无法区分的会话:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
我使用项目真实的验证逻辑和实际的 jsonwebtoken 依赖对此进行了验证:使用恢复出的密钥签署的令牌被接受,而使用错误密钥签署的同一令牌被拒绝。对照实验至关重要——没有它,你得到的只是假设,而不是发现。
第 4 步——并行路径。 仅凭 DATABASE_URL 就足以直接访问 Postgres:读取每一个已连接的账户,或直接翻转管理员标志。
无需密码。无需先前访问权限。无需用户交互。一次未经验证的 HTTP 请求。
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N 和 UI:N 是起决定性作用的两个度量。该路由不需要会话,也不需要受害者交互——攻击者通过网络独自行动,针对默认配置发起攻击。
唯一诚实的限制因素是配置门:使用 S3 或 R2 的部署不会受到攻击,因为路由被重写到 /404。这缩小了受影响人群的范围,但不会降低其中任何人的严重程度——而且 local 是随附的默认值。
维护者的补丁(7936062)只有八行,值得一读,因为它的正确性正是这类修复常常欠缺的:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
它有两点做得很对:
resolve() 在解码完成后压缩 ..,因此遍历如何绕过路由被走私进来并不重要。检查现在位于危险所在之处。base + sep 比较,而非 base。 朴素的 filePath.startsWith(base) 会接受 /app/uploads-evil/x 作为 /app/uploads 内部路径——这是经典的前缀匹配绕过。追加分隔符堵住了它,而 filePath !== base 子句确保目录本身仍然有效。这就是包含性检查的正确形态:先解析,再与带尾部分隔符的基路径比较。
除非另有说明,时间均为 UTC,2026-07-20。
| 时间 | 事件 |
|---|---|
| 05:55 | 公告报告给 Postiz 团队 |
| 07:44 | 维护者确认并验证 |
| 12:18 | 修复已提交、验证并发布 |
| 2026-08-07 14:13 | CVE-2026-19264 由 Postiz(CNA)分配 |
从报告到补丁发布仅用时六小时二十三分,而且这是一个没有漏洞赏金计划的开源项目。我曾见过一些报告在配备专职安全团队的组织中搁置数月无人处理。感谢 Enno Gelhaus 负责协调,感谢 Nevo David 完成修复。
一项能通过你测试的控制,并不意味着它处于正确的位置。 那个 404 是真实存在的。Next.js 确实会压缩 ../。只是这道防御在输入完成解码之前就执行了,这意味着它保护的是路由器,而不是文件系统调用。当你发现一处缓解措施时,要问它相对于数据汇何时执行——而不仅仅是它是否存在。
编码是一层,而各层被剥离的速度不同。 只要请求管线中的两个组件对解码次数产生分歧,它们之间的间隙就可被利用。路由匹配器、中间件和处理程序经常存在分歧。
要根据进程能触及到什么来评定文件读取原语,而不是根据原语本身。 "任意文件读取"听起来像是信息泄露。它之所以变成严重级别,是因为环境可读、其中的密钥签署会话、而这些会话永不过期。在评分之前,先顺着链条走一遍。
永不过期的令牌会把泄露变成永久性入侵。 短生命周期令牌的签名密钥泄露已经是很糟糕的一天。在没有 expiresIn 的情况下,不轮换密钥就无法挽回——而大多数运维人员永远不会知道自己需要轮换。
运行对照实验。 验证使用错误密钥签署的令牌会被拒绝,这正是区分"已证实的发现"与"想当然的发现"的关键。
如果您自托管 Postiz:
STORAGE_PROVIDER=local 运行过受影响版本,请假定 JWT_SECRET 已泄露。 轮换它。由于令牌没有过期时间,轮换是使任何已伪造令牌失效的唯一方法。DATABASE_URL 凭据以及任何已连接提供商的 OAuth 密钥。%2e 或 %2f 的 /uploads/ 的 GET 请求。研究独立进行,并依据协调披露流程向供应商披露。在修复发布且公告公开后发表。未访问任何第三方系统——所有验证均针对从项目自身源码构建的本地实例进行。
本文遵循 CC BY 4.0 许可——可自由共享和改编,但需署名。来自 gitroomhq/postiz-app 的代码摘录仅用于安全分析引用,仍受该项目许可证约束。
Krithik Babu P - @DarkLycn1976
| 2026-08-07 14:15 | GitHub 安全公告发布 |