Skip to content

fix(fs): 修复文件系统服务器的路径越界检查漏判同级目录 - #4397

Merged
rintim merged 1 commit into
libnoname:mainfrom
MaxFreedomPollard:fix/fs-path-escape
Oct 3, 2026
Merged

rintim merged 1 commit into
libnoname:mainfrom
MaxFreedomPollard:fix/fs-path-escape

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

变更内容

packages/fs/src/index.ts 的 isInProject 改成先 path.resolve 拿到规约后的绝对路径,再要求它等于 dirname 或以 dirname + path.sep 开头。

-	const isInProject = (url: string) => path.normalize(join(url)).startsWith(dirname);
+	// 比较前给 dirname 补上路径分隔符,否则同级的 `${dirname}-backup` 之类目录会被当成项目内路径
+	const prefix = dirname.endsWith(path.sep) ? dirname : dirname + path.sep;
+	const isInProject = (url: string) => {
+		const full = path.resolve(join(url));
+		return full === dirname || full.startsWith(prefix);
+	};

原来是纯前缀匹配。join 就是 path.join(dirname, url),而 path.join 会先把 .. 规约掉,所以 dirname 为 /a/noname 时,url 传 ../noname-backup/flag.txt 得到 /a/noname-backup/flag.txt,这个字符串以 /a/noname 开头,startsWith 返回 true,ensureSafe 放行。../../../etc/hosts 这类结果不以 dirname 开头的越界一直是拦得住的,漏掉的只有“同级且名字以 dirname 为前缀”的目录。

所有走 ensureSafe 的路由都受影响:/readFile、/readFileAsText、/getFileList、/checkFile、/checkDir、/createDir、/writeFile、/removeFile、/removeDir。@fastify/cors 配的是 origin: "*",所以浏览器里任何页面都能对本机这个服务器发这些请求。

验证方式

写了个脚本直接 import packages/fs/src/index.ts 起服务器,dirname 指向临时目录 probe/root,同级放一个 probe/root-secret,再用 fetch 打 13 个用例:7 个目录内的正常操作(读根目录文件、读子目录文件、dir= 空串列根目录、dir=. 列根目录、建子目录、写子目录文件、checkFile)和 6 个越界(../../../etc/hosts,以及同级目录的读文件、列目录、checkFile、建目录、写文件)。同一份脚本在 main 和本 PR 上各跑一次:

用例 失败
main 13 5
本 PR 13 0

main 上失败的 5 个全是同级目录那 5 条,都返回 success: true,../root-secret/pwned.txt 也真的被写出来了。../../../etc/hosts 两边都被拦住,7 个正常操作两边都通过。

又按 pnpm dev 的方式起了真实的开发服务器(pnpm -F @noname/fs dev --dirname=../../apps/core)配 vite(pnpm -F noname dev):客户端的启动探针 /checkFile?fileName=noname.js 返回 "file",/index.html 是 200,/getFileList?dir=character 正常列出全部武将包,经 vite 代理的 /writeFile → /readFileAsText → /removeFile 一整轮存档读写正常。同时 ../electron/package.json 和临时造的同前缀目录 apps/core-x/leak.txt 都被拦住了。

Lint 按 CI 的命令跑的,packages/fs、apps/core、apps/electron 三个包全过:

pnpm --filter "...[main]" --filter "!./" lint
pnpm exec prettier --check packages/fs/src/index.ts   # All matched files use Prettier code style!

pnpm start 没跑:本地是稀疏检出,image/、audio/ 这些资源目录没拉下来,pnpm build 出不了完整的 dist。pnpm serve 跑的就是 @noname/fs,上面已经用 apps/core 当 dirname 直接起过同一个服务器了。

packages/fs/src/index.ts 的 isInProject 用 path.normalize(join(url)).startsWith(dirname) 判断路径是否在项目内,这是纯前缀匹配。join 就是 path.join(dirname, url),而 path.join 会先把 .. 规约掉,所以 dirname 为 /a/noname 时,url 传 ../noname-backup/x 得到的是 /a/noname-backup/x,它以 /a/noname 开头,检查通过。

结果是所有走 ensureSafe 的路由(/readFile、/readFileAsText、/getFileList、/checkFile、/checkDir、/createDir、/writeFile、/removeFile、/removeDir)都能读写到 dirname 同级、且名字以 dirname 为前缀的目录。../../../etc/hosts 这类结果不以 dirname 开头的越界一直是拦得住的。

改为先 path.resolve 拿到规约后的绝对路径,再要求它等于 dirname 或以 dirname 加路径分隔符开头;dirname 本身是根目录时不重复补分隔符。
@rintim
rintim merged commit 4df78f4 into libnoname:main Oct 3, 2026
1 check passed
rintim pushed a commit that referenced this pull request Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants