Deno 在一个默认不授予任何权限的沙箱中运行你的代码:文件系统、网络、环境变量、子进程、系统信息以及原生库(FFI)全部处于关闭状态,直到你用 --allow-* 标志逐项开启为止;而且几乎每个标志都接受一个参数,用于把授权范围收窄到指定的路径、主机或变量。
如果你是从 Node 转过来的,你写的第一个 Deno 脚本几乎必然会因为权限错误而直接中断,而解决办法很少是肌肉记忆里那个一把梭的 -A。搞清楚该加哪个标志、以及范围该收到多紧,正是学习曲线的主要部分。
这与 Node.js 历来的默认行为正好相反,也是运行任何 Deno 脚本之前最需要理解的一件事。本文将逐一拆解:默认被阻止的能力有哪些、每一个 --allow-* 与 --deny-* 标志及其范围语法、Deno 2.x 的新增内容(deny 优先、--allow-sys、--allow-import、env 通配符)、deno.json 中的权限集、运行时 Permissions API,以及文档中提及最少的两个安全”暗坑”。
要点速览
- 默认情况下,Deno 代码无法读写文件、建立网络连接、读取环境变量、派生子进程、访问系统信息或加载原生库。你需要通过
--allow-*标志按能力逐项选择开启。 - 每个
--allow-*标志都有对应的--deny-*,且 deny 始终优先:--allow-read=. --deny-read=./secrets授权整个项目目录,但./secrets仍然不可读。 - 在 Deno 2 中,被拒绝的能力会抛出
Deno.errors.NotCapable(由旧的PermissionDenied重命名而来),从而把 Deno 的权限拒绝与普通的操作系统错误区分开来。 - 自 Deno 2.5 起,你可以在
deno.json中定义具名权限集,并用-P=name应用(或用裸-P应用default权限集),把最小权限标志纳入版本控制。 - 初始静态 import 图中的任何内容在加载前都不会经过权限系统的检查,而
--allow-run派生的子进程运行在沙箱之外。这就是不可信代码逃逸的两条路径。
为什么 Deno 默认是安全的?
没有任何东西带着环境权限(ambient privileges)运行:磁盘、网络、环境变量和子进程派生全部保持关闭,直到你主动打开。这一设计决策直接来自 Node 的最初作者 Ryan Dahl,他构建 Deno 就是为了扭转 Node “对一切都有完全访问权”的默认行为。在 Deno 中,依赖本身不会获得任何环境权限;而在 Node 中,一个包会继承所在进程能够触及的全部系统 I/O,这一差距是两个运行时之间最鲜明的区别。
Node 后来也加入了自己的权限模型。它在 Node 20 中以 --experimental-permission 的形式实验性发布,在 v23.5.0 中被标记为稳定,而 Node 24 则弃用了实验性写法,改用简洁的 --permission。Deno 的模型依然更为深入:它是默认行为而非可选标志,覆盖的能力类别更多,范围控制也更细。
Discover how at OpenReplay.com.
Deno 的 —allow-* 权限标志有哪些?
每一类能力对应一个标志,且大多数标志接受一个允许列表参数。裸标志会授予该类别下的全部权限;带参数则会收窄范围。裸 --allow-net 授予对所有主机所有端口的访问权,而 --allow-net=api.example.com:443 则把程序限制为仅访问这一个主机和端口。
| 标志 | 守护的能力 | 限定范围示例 | 对应的 deny 标志 |
|---|---|---|---|
--allow-read | 文件系统读取 | --allow-read=./data,config.ini | --deny-read |
--allow-write | 文件系统写入 | --allow-write=./tmp | --deny-write |
--allow-net | 网络访问 | --allow-net=api.example.com:443 | --deny-net |
--allow-env | 环境变量 | --allow-env=PORT,HOST | --deny-env |
--allow-run | 子进程 | --allow-run=git,deno | --deny-run |
--allow-sys | 系统信息 API | --allow-sys=hostname | --deny-sys |
--allow-ffi | 原生库 | --allow-ffi=./lib.so | --deny-ffi |
--allow-import | 远程 HTTPS 导入 | --allow-import=jsr.io | --deny-import |
注意,已不存在 --allow-hrtime。该标志在 Deno 2.0 中被移除,如今像 performance.now() 这样的高精度计时 API 始终可用。
当脚本需要一项你未授予的权限时,Deno 会暂停并交互式提示:
┏ ⚠️ Deno requests net access to "deno.com:443".
┠─ Requested by `fetch()` API.
┗ Allow? [y/n/A] (y = yes, allow; n = no, deny; A = allow all net permissions) >
回答 y 表示本次授予,n 表示拒绝(会抛出 Deno.errors.NotCapable),A 表示允许整个类别。在 CI 环境中,请预先传入标志,以免因等待提示而阻塞。
标志集是如何演进的:deny 优先、--allow-sys、--allow-import、env 通配符
deny 标志在 Deno 1.36(2023 年 8 月)中落地,此后每个 --allow-* 标志都配有对应的 --deny-*。两者重叠之处,生效的是拒绝规则,这让你可以先宽泛授权,再挖出例外:
deno run --allow-read=. --deny-read=./secrets app.ts
--allow-sys 可追溯至 Deno 1.26(2022 年 10 月),它管控诸如 Deno.hostname() 和 Deno.systemMemoryInfo() 之类的系统信息 API。Deno 2.0 中唯一真正新增的能力类别是 --allow-import,它决定你的代码在运行时可以从哪些 HTTPS 主机拉取模块;纯 HTTP 从不被允许,静态 import 会自动按该列表过滤,而且指定你自己的主机是替换Deno 的内置集合,而非在其之上追加。使用 --deny-import 可以直接彻底屏蔽特定主机。
环境变量访问在 Deno 2.1 中获得了后缀通配符支持。无需逐个列出变量,可按前缀限定范围:
deno run --allow-env="AWS_*" main.ts
在 deno.json 中声明权限
自 Deno 2.5 起,你可以在 deno.json 中定义具名权限集,并用 -P=name(或 --permission-set=name)应用,从而把最小权限标志纳入版本控制,而不必每次运行都重新输入。对象的键就是标志名(read、write、net、env、sys、run、ffi、import),详见 deno.json 参考文档:
{
"permissions": {
"default": {
"read": ["./deno.json"],
"env": true,
"run": { "allow": ["git"] }
},
"process-data": {
"read": ["./data"],
"write": ["./data"]
}
},
"tasks": {
"dev": "deno run -P main.ts"
}
}
运行 deno run -P=process-data main.ts 使用具名权限集,或运行 deno run -P main.ts 使用 default 权限集。Deno 2.5 还新增了 DENO_AUDIT_PERMISSIONS 环境变量:将其指向一个文件路径,Deno 就会为程序触及的每一项权限追加一条 JSONL 记录,无论该访问是被授予还是被拒绝。这是快速摸清一个脚本真正需要什么权限的好办法。
运行时 Permissions API
在执行受限操作之前先在代码中查询权限,可以优雅降级,而不是直接因 NotCapable 错误崩溃。Deno.permissions 提供了 query、request 和 revoke,每个都接受一个形如 { name: "net", host: "example.com" } 的描述符:
const desc = { name: "net", host: "example.com" } as const;
let status = await Deno.permissions.query(desc); // "prompt" | "granted" | "denied"
if (status.state === "prompt") {
status = await Deno.permissions.request(desc); // triggers the y/n/A prompt
}
if (status.state === "granted") {
await fetch("https://example.com");
}
await Deno.permissions.revoke(desc); // drop it again
query 报告当前状态且不触发提示,request 在状态仍为 prompt 时向用户发起提示,revoke 则交还某项能力。这让长时间运行的程序可以在触及资源之前先做检查,并在访问不可用时走另一条路径。
两个暗坑:imports 与 --allow-run
有两种行为会让不可信代码绕过沙箱,两者都值得牢记于心。第一,凡是 Deno 能从你的入口点静态解析出来的东西(本地文件、npm 与 JSR 包,以及以字符串字面量写死的远程 URL)都会在权限系统介入之前被抓取,因此一个依赖可以在你的第一个 --allow-* 标志生效之前就读取自身源码并访问网络。这张”免票”仅限于加载阶段,别无其他:代码一旦执行,每个操作都会重新受到检查。--allow-import 限定的是哪些远程主机可被导入,但并不会让 import 本身需要运行时授权,所以在引入第三方代码之前请先审计。
第二,--allow-run 是最锋利的暗坑:你派生出来的东西会成为一个独立的进程,携带的是操作系统赋予它的权限,而不是你交给 Deno 的那一小套权限。也就是说,--allow-run=deno 会让一个受沙箱约束的脚本用 --allow-all 重新启动 Deno,从而完全逃逸。它还只限制哪个可执行文件能运行,而不限制其参数:--allow-run=cat 就意味着代码可以通过 cat 读取任意文件。请把它限定到特定的可信二进制文件,例如 --allow-run=git;同时请注意 --allow-ffi 存在同一类风险,因为原生库以机器码形式执行,处于 JavaScript 层检查之外。
实践立场是:授予能跑通的最窄允许列表,对敏感路径叠加 --deny-*,并把 --allow-run 和 --allow-ffi 当作信任边界而非便利工具。从零权限起步,运行脚本,然后严格按照提示(或 DENO_AUDIT_PERMISSIONS 日志)告诉你的内容逐项补回。
常见问题
Deno 权限提示中选择拒绝与 Deno.errors.NotCapable 有什么区别?
两者是同一结果的不同入口。当你对交互式提示回答 'n',或者在缺少所需标志的情况下运行时,被拒绝的操作会在 Deno 2 中抛出 Deno.errors.NotCapable(由旧的 PermissionDenied 重命名而来)。这次重命名让你可以把 Deno 自身的权限拒绝与诸如文件缺失之类的普通操作系统错误区分开来,因为二者此前呈现出的失败形态非常相似。
--allow-net=example.com 是否也允许 443 端口上的 HTTPS?
是的。当你只指定主机而不指定端口时,例如 --allow-net=example.com,Deno 会允许连接该主机的任意端口,包括 443。若要限制为单个端口,必须显式写成 --allow-net=example.com:443,这样该主机上的其他所有端口都会被阻止。不带参数的裸 --allow-net 则授予所有主机所有端口的访问权。
我可以在重叠路径上同时使用 --allow-read 和 --deny-read 吗?
可以,而且 deny 始终优先。运行 --allow-read=. --deny-read=./secrets 会授予对整个项目目录的读取权,但 ./secrets 除外,它仍然不可读。在每一类能力中,deny 标志都优先于 allow 标志,因此这种模式让你可以宽泛授权后再挖出敏感路径,而不必逐个列举每一个允许的文件。
使用 npm 或 JSR 包需要 --allow-import 吗?
不需要,对于静态导入的包无需如此。凡是 Deno 无需执行代码即可从入口点解析出来的内容,包括 npm 和 JSR 包,都会在权限系统被征询之前被抓取。--allow-import 决定的是远程 import 可以来自哪些 HTTPS 主机,而纯 HTTP 从来都不是选项。运行时计算出的说明符则不同:动态远程 URL 需要 --allow-import,动态本地路径需要 --allow-read。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k