解读 Node.js 20 推出的权限模型
大家好,我是 ConardLi。
4月18号,Node.js
发布了 V20
版本,当前处于 Current
状态,预计会在今年 10 月份进入长期支持状态。
Node.js 20
算得上是一个相当大的版本了,其中带来了非常多的新特性,我先简单说说这些特性都是用来干啥的:
-
新的权限模型:提供了
Node.js
中敏感API
的权限管控能力 -
ESM Loader
:可以在一个与主线程隔离的专用线程上自定义ESM Loader
-
import.meta.resolve
:可以将特定上下文的元数据共享给JavaScript
模块 -
url.parse()
:可以识别端口号不是数字的URL
,比如https://conardli.top:abc
(ULR 的端口号一定是数字,这里其实是修了个 Bug) -
url.parse()
:一个新的 URL 验证方法,在我这篇文章中有介绍过了:Node.js 支持原生 URL 验证方法,Node.js 14 即将停止维护! -
test_runner
:Node.js
提供了原生单元测试工具,已经稳定可用 -
V8
引擎更新到 11.3 版本:支持了下面的方法:String.prototype.isWellFormed
:检测字符串格式是否正确reverse、toReversed
等非破坏性数组方法,在我之前的文章中有详细介绍过:ECMAScript 2023 有哪些新东西?ArrayBuffer、SharedArrayBuffer
可以调整容量大小
基本上主要的更新就是以上这些内容,其他的都没啥好展开讲的,今天我们主要一起来看看权限模型。
为什么要有权限模型?
首先明确一点,Node.js 20
的权限模型基本上是从 Deno
那抄过来的(目前看来只抄了个半成品)。
因为 Deno
在设计之初就考虑了权限设计,所以它可以很自豪的说:Deno is secure by default.
但是 Node.js
就不一样了,因为在一开始的设计中对安全考虑不足,也没有类似的权限模型的设计,所以频繁的被爆出各种漏洞,Node.js
官方还有一个专门的安全相关的文档,来告诉大家使用 Node.js
有什么样的风险:
而这些内容是需要开发者自己去规避的(但实际上大部分开发者不会主动关注,所以写出的很多代码都是有漏洞的)。
举个例子,Node.js
提供了很多操作文件的能力,比如下面这些 API
:
- fs:
createReadStream / createWriteStream / appendFileSync / appendFile
... - fs-extra:
createFile / createFileSync / ensureDir / ensureDirSync / mkdir
... - shelljs:
cat / cd / chmod / cp / pushd / popd / dirs / echo / find / grep
...
假设我们把一个不可信的用户输入,传入到这些 API
的参数中,因为这意味着外部可以随意更改传入的参数,也就是说可以随意遍历我们的目录。
比如下面是一个实际的业务场景,我们在服务器的 './image'
这个相对路径下存储了一些图片,我们通过在参数中传入的图片名称来读取这张图片,代码如下:
function readImageg(ctx) {
const { name } = ctx.query;
const content = fs.readFileSync('./image' + name);
return content;
}
看起来好像没什么问题,但是假如攻击者将 name
参数改为 ../../../../../../../etc/hosts
呢?
你可能会说,攻击者哪知道你的服务器有什么路径?
那攻击者可以随便传,随便试啊,也可以写脚本暴力测试,这就意味着任何人通过这个接口都可以读取你服务器上的任意文件。
就算是一个典型的路径遍历漏洞。
据我了解,有相当一部分 Node.js
开发者在写到类似的功能的时候会犯这种问题,因为他根本不会意识到有什么样的安全风险。
其实,这个问题的本质原因就是 Node.js
提供了很多高风险的 API
,但是又没有给这些 API
比较严格的限制。所以 Node.js
不能说是默认安全的。
但是 Deno
可以说 Deno is secure by default.
,主要就是因为他提供了一套完整的权限模型。
上面提到了那么多可以读取文件的 API
,那么你在程序启动的时候就要告诉我:
这个程序是不是拥有读取文件的能力?如果要读取的话能读取哪些文件?
如果你没有告诉我,那我默认就不让你执行,如果你执行的路径没在我的白名单之内,那也不允许执行,这就是 secure by default
。
整个权限模型的核心思维就是这样的,下面我们来具体看看。
下面会有一些 Demo
,大家也可以自己在本地试试,首先要通过 nvm
升级到 20
版本:
nvm install 20.0.0
Downloading and installing node v20.0.0...
Downloading https://nodejs.org/dist/v20.0.0/node-v20.0.0-darwin-x64.tar.xz...
################################################################################################################################################# 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v20.0.0 (npm v9.6.4)
文件系统的权限
上面我们提到 fs
模块为我们提供了很多和文件系统交互的 API
,在没有启用权限标志的情况下,我们可以随便读取文件,首先我们创建一个 file17.js
来测试下:
const fs = require('node:fs/promises');
async function readFile() {
try {
const data = await fs.readFile('/Users/bytedance/Desktop/learn/Node.js/17.txt', {
encoding: 'utf8',
});
console.log(data);
} catch (err) {
console.log(err);
}
}
readFile();
我们执行 node file17.js
,它可以正常打印出内容:
node file17.js
171717171717
下面我们加上权限系统的标志 --experimental-permission
,在执行这个文件就会报错:
如果想要访问或文件系统,就必须要指定下面两个标志:
--allow-fs-read
:允许读取某个路径下的文件,多个可以用逗号分隔;--allow-fs-write
:允许操作某个路径下的文件,多个可以用逗号分隔;
大家再回想一下上面提到了路径遍历漏洞的场景,如果我们的程序一开始设计的时候,启动就加上了 --experimental-permission
,那么默认就是不允许操作文件的,如果后面有了需要操作文件系统的需求,就要强制开发者通过 --allow-fs-read、--allow-fs-write
去设置白名单,那么我就可以说是默认安全的了。
检查权限
Node.js 20
还提供了一个 process.permission.has API
,可以用来帮助我们在运行时检查程序是不是拥有指定的权限。我们使用刚刚的例子,在下面增加一些判断权限的代码:
const fs = require('node:fs/promises');
async function readFile() {
try {
const data = await fs.readFile('/Users/bytedance/Desktop/learn/Node.js/17.txt', {
encoding: 'utf8',
});
console.log(data);
} catch (err) {
console.log(err);
}
}
readFile();
console.log('通用文件读取权限', process.permission.has('fs.read'));
console.log(
'/Users/bytedance/Desktop/learn/ 目录的读取权限',
process.permission.has('fs.read', '/Users/bytedance/Desktop/learn/')
);
console.log('通用文件操作权限', process.permission.has('fs.write'));
console.log(
'/Users/bytedance/Desktop/learn/ 目录的操作权限',
process.permission.has('fs.write', '/Users/bytedance/Desktop/learn/')
);
我们执行一下这个命令:node --experimental-permission --allow-fs-read=/Users/bytedance/Desktop/learn/ file17.js
:
可以很清晰的告诉我们程序在运行时拥有什么权限。
子进程的权限
Node.js
中的 child_process
为我们提供了创建子进程的能力,我们可以在这个子进程中执行任意的 Shell
命令。
这个命令也是相当危险的,原理和上面的路径遍历一样,如果把用户可控的参数传入到了子进程执行的命令中,就是命令注入漏洞,这个要比路径遍历漏洞还严重的多。
我们创建一个 child17.js
文件来测试执行一个简单的 ls
命令:
const { spawn } = require('node:child_process');
const ls = spawn('ls', ['/Users/bytedance/Desktop/learn/Node.js']);
ls.stdout.on('data', (data) => {
console.log(`输出如下:\n${data}`);
});
ls.stderr.on('data', (err) => {
console.error(`失败: ${err}`);
});
ls.on('close', (code) => {
console.log(`子进程推出 ${code}`);
});
执行结果如下,可以正常遍历目录:
但如果我们加上了 --experimental-permission
标志,执行命令的时候就会报错:
我们必须要加上 --allow-child-process
才能正常允许执行子进程。另外,如果子进程中读取到了一些文件目录,我们依然需要加上 --allow-fs-read
才能执行成功,下面我们执行这个命令:
node --experimental-permission --allow-child-process --allow-fs-read=/Users/bytedance/Desktop/learn/ child17.js
结果如下:
worker_threads 权限
worker_threads
可以让我们创建多线程,然后在不同的线程中并行执行代码,一般我们会开多个 Workers
来执行一些 CPU
密集型的操作。
我们创建一个 threads17.js
来测试执行下面的代码:
const { Worker, isMainThread, workerData } = require('node:worker_threads');
if (isMainThread) {
new Worker(__filename, { workerData: 1});
new Worker(__filename, { workerData: 2});
console.log(`Inside Main Thread: isMainThread = ${isMainThread}`);
} else {
console.log(`Inside Worker: isMainThread = ${isMainThread}, and my ID is ${workerData}`);
}
代码里面创建了两个简单的 Worker
来并行执行代码,执行代码可以正常输出:
但如果我们启用了 --experimental-permission
标志,代码执行就会报错:
如果要使用 worker_threads
,比如要添加一个 --allow-worker
的标志,另外它还需要通过 --allow-fs-read
来告诉它哪些目录下的文件是允许被执行的,所以我们执行下面的代码:
node --experimental-permission --allow-worker --allow-fs-read=/Users/bytedance/Desktop/learn/ threads17.js
这样就可以正常执行了:
最后
目前 Node.js
的权限模型还是相当初级的阶段,还有很多能力需要完善,比如可以限制服务的网络请求白名单,这样就可以避免 SSRF
漏洞等等,而且现在还在实验阶段,大家尽量不要在生产环境使用。
参考:
- nodejs.org/en/blog/ann…
- nodejs.org/api/permiss…
- nodejs.org/en/docs/gui…
- betterprogramming.pub/6-major-fea…
如果你想加入高质量前端交流群,或者你有任何其他事情想和我交流也可以添加我的个人微信 ConardLi 。