如何在 Deno 中方便且安全地使用第三方库?
Node 的安全问题是一直被诟病的,社区大、很多没有被维护好的库,而 Node 没有提供访问权限的控制,所以引入一个依赖包时它有可能做任何事。
Deno 被提出时要解决的问题之一就是安全。所以 Deno 提供了进程上或 Worker 上的访问控制,每次运行 Deno 程序时都要指明程序的权限:
- 是否可读
- 是否可写
- 是否可以访问网络
- 是否可以运行子命令
- ...
但目前 Deno 还不支持在 import 时指定一个包的访问权限,因为相关实现相当麻烦。而这个需求又是普遍存在的:我们有一个庞大的社区,社区仓库相当多,其中也龙蛇混杂,在使用一个库的时候很少有人会有精力去检查/跟踪安全问题,而如果不能解决信任问题我们可能更倾向于不使用或是 fork 一个库。
Deno issues 上的相关讨论:Sandbox mode for external scripts。 因为 Deno 是遵照 ECMA 的标准走的,所以等到沙盒相关的提案被加入 ECMAScript 标准后 Deno 就大概率会有官方的沙盒支持。其中包括了到 Stage 3 的 realms 和还处于 Stage 1 的ses
方案
那目前我们有什么方式可以 100% 放心地引入一个包呢?
Workers 是目前最佳方案。Deno 中的 Worker 除了加载 js 也可以直接加载 deno 格式的 TypeScript 的代码。每个 Worker 在创建时都可以细粒度地指定权限,例如对哪些文件可读,对哪些可写,只允许访问哪些域名。 Worker 中可以再开启子 Worker,子 Worker 能继承父亲的权限或是被限制得更严格。
const worker = new Worker(new URL("./worker.ts", import.meta.url).href, {
type: "module",
deno: {
namespace: true,
permissions: {
net: [
"https://deno.land/",
],
read: [
new URL("./file_1.txt", import.meta.url),
new URL("./file_2.txt", import.meta.url),
],
write: false,
},
},
});
但通过 Worker 就会有大量重复性的代码,有没有什么库可以帮我们做这个事呢?
Comlink
Comlink 是让用户能够更自然使用 Worker 的一个库,只要在正常 worker 操作前加上 await 就可以使用 worker 导出的对象。
并且 2021 年 7 月发布的 Deno 1.12 支持了 MessageChannel 和 Message Port,这让 Comlink 的特性能够在 Deno 上被完全发挥(例如下例中向 Worker 传递一个 callback,其实依赖了 Message Port 的接口)。想了解 Comlink 的具体实现原理可见这篇文章。
通过 Comlink 实现在 Worker 上插入和查询:
main.js
import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";
const DataProcessor = Comlink.wrap(new Worker("./worker.js"));
let processor = await new DataProcessor();
export async function insert(s) {
await processor.insert(s);
}
export async function search(s) {
return await processor.search(s);
}
export async function map(callback) {
return processor.map(Comlink.proxy(callback));
}
worker.js
importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
class DataProcessor {
arr = [];
constructor() {}
insert(...s) {
this.arr.push(...s);
}
search(target) {
const ans = [];
for (const value of this.arr) {
if (value.str.match(target)) {
ans.push(value);
}
}
return ans;
}
map(callback) {
return Promise.all(this.arr.map(callback));
}
}
Comlink.expose(DataProcessor);
Comlink + Deno Worker
从而我们可以提供一个 Comlink + Workers 的封装,暴露一个新的 import 方法,从而我们就可以安全地 import 任何脚本啦。
但刚想动手做的时候发现了一样想法的仓库 importw
使用方式为
import {
importw,
release,
worker,
} from "https://deno.land/x/importw@1.0.0/mod.ts";
// 在 worker 内 import 一个包
// log, add 是包所提供的函数
// release 和 worker 是内置地 symbol,用于获取 worker 的操作对象
const { log, add, [release]: terminate, [worker]: workerRef } = await importw(
"https://deno.land/x/importw@1.0.0/examples/basic/exampleMod.ts",
{
name: "exampleWorker",
deno: {
namespace: true,
permissions: {
net: [
"deno.land",
],
write: false,
},
},
},
);
// 访问内部的 Worker 的方式
console.log(workerRef.constructor.name); // Worker
// 在 Worker 内执行代码
await log(`add(40, 2) in a worker:`, await add(40, 2));
// Gracefully release Worker resources
await terminate();
添加类型目前会略显啰嗦
import type * as M from "https://deno.land/x/importw@1.0.0/examples/basic/exampleMod.ts";
...
const ... = await importw<{log: typeof M.log, add: typeof M.add}>(
...
);
但要注意通过这种方式也达不到 100% 的安全。如果是长时间运行的脚本还有可能受到 side channel timing attacks。
通过这种方式引入包之后除了沙盒也并行化了 挺好 🌝