javascript - 如何对在画布上绘制的任意用户提交的 Javascript 代码进行沙箱处理?
问题描述
我正在尝试构建一个应用程序,用户可以在其中编写自定义 Javascript 代码以便在画布上绘制图形。我希望用户能够与其他人共享此代码,并让他们在浏览器中安全地运行其他人贡献的绘图功能。
我正在寻找的基本上是一个 Javascript 沙箱,可以防止任何重大安全漏洞。我还希望能够在 Web Worker 中执行繁重的绘图逻辑,以免阻塞主线程并保持 UI 响应。
到目前为止,我已经提出了一个使用 iframe、多个域和 Web Worker 的最小实现,如下所示:
- 用户在页面上的画布上绘图(例如,从 www.drawing.com 检索)。
- 当画布上发生鼠标事件时(例如用户点击它),一个 postMessage() 被执行到来自不同来源的 iframe (www.drawing-scripts.com)。postMessage 提供了应该运行的代码(作为字符串),以及一堆自定义库函数(例如 circle()、line())和一些用于绘图的附加参数(例如笔画粗细)。在这里,使用不同的来源可以防止任何在 iframe 中评估的代码访问从主要来源存储的 cookie(例如,与身份验证相关的)。iframe 使用
sandbox
属性和allow-scripts
属性进行沙盒化,以允许其运行 JS 代码。 - iframe 以字符串的形式接收代码和库,并对加载时生成的 web worker 执行 postMessage()。这样做是为了避免在(可能)高开销的绘图操作期间阻塞主浏览器线程并阻塞 UI 渲染。
- Web Worker 在加载时和在任何代码发送给它之前,使用白名单删除所有其他功能,就像在Node-SO-bot中所做的那样。
- 然后,网络工作者从 iframe 接收带有代码和库作为字符串的消息,并评估自定义代码。
- 我提供的库函数(例如
circle()
和line()
)只是将绘图信息附加到列表中。这从 web worker 通过 a 返回到 iframepostMessage()
,然后通过 another 返回到主页面postMessage()
。最后,主页面简单的在画布上一一进行列表中的绘制操作。
所以流程基本上看起来像
mouseClick --> postMessage 代码到 iframe --> postMessage 代码到白名单的 web worker --> eval() 代码,将绘图操作附加到列表 --> 将绘图操作列表返回到 iframe --> 将绘图操作列表返回到主页 --> 按要求执行操作。
这目前有效,但有一些问题:
- 每次操作都有很长的往返时间,这大大减慢了速度。在实际绘图发生之前有很多数据被传递,这使得事情明显变慢。
- 跨不同的代码片段维护状态很困难。考虑我有一个代码片段运行 setup() 函数,该函数预先计算一些数据,然后在用户与画布交互时绘制圆圈,而另一个预先计算其他内容并改为绘制三角形,我希望用户能够在它们之间切换。我希望每个片段都有能够使用全局范围的错觉,但目前尚不清楚如何做到这一点。
- 如果在 eval'ed 代码中发生错误,则很难找出行号,尽管这似乎只是 eval() 的一个问题。
- 最后,尚不清楚该方案的安全性以及流程中是否存在任何漏洞。
- 有没有办法简化这个?我已经看到沙盒网站以这种方式使用 iframe 来沙盒代码,但是我想要使用多个代码片段在画布上绘制额外的复杂性。这已经在某个地方完成了吗?
解决方案
你不需要这一切。
根据您的第2点,您的用户没有传递必须执行的“任意”代码。
他们发送的只是您的应用程序生成的数据、坐标、颜色、可能是文本或您的应用程序将处理的其他内容。
你绝对不需要eval
。
您需要的是一种对应用程序命令进行字符串化,然后对其进行解析的方法。
例如,如果您的用户从 10、10 到 15,15 画一条线,您可以存储类似
{ type: "line", x1: 10, y1: 10, x2: 15, y2: 15 }
甚至 SVG 之类的M10,10L15,15
.
然后,当您检索用户的数据时,您只需解析它并调用匹配的绘图命令。
const ctx = canvas.getContext('2d');
inp.oninput = e => parseAndDraw(inp.value);
const availableCommands = {
clear({x=0, y=0, w=canvas.width, h=canvas.height}) {
ctx.clearRect(x, y, w, h);
},
beginPath(cmd) {
ctx.beginPath();
},
line({x1, y1, x2, y2}) {
if(!isNaN(x1))
ctx.lineTo(x1, y1);
ctx.lineTo(x2, y2);
},
stroke({color = "black", lineWidth = 1}) {
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
}
inp.oninput();
function parseAndDraw(str) {
// a few checks
try {
commands = JSON.parse(str);
}
catch(e) {
return wrongData("invalid JSON format");
}
if(!Array.isArray(commands)) {
return wrongData("must be an Array of commands");
}
if(
commands.some(command => !command || typeof command.type !== 'string')
) {
return wrongData("all commands must have a type");
}
commands.forEach(draw);
console.clear();
}
function draw(command) {
const known_command = availableCommands[command.type];
if(typeof known_command !== 'function') {
return wrongData("unknown command");
}
known_command(command);
}
function wrongData(reason) {
console.clear();
console.error('[wrong data]:', reason);
}
canvas { border: 1px solid; }
#inp { width: 100%; height: 100vh; background: ivory }
<canvas id="canvas"></canvas><br>
<textarea id="inp" row="15">
[
{
"type": "clear",
"x": 0,
"y": 0,
"w": 300,
"h": 150
},
{
"type": "beginPath"
},
{
"type": "line",
"x1": 10,
"y1": 10,
"x2": 35,
"y2": 35.5
},
{
"type": "line",
"x2": 80,
"y2": 35.5
},
{
"type": "line",
"x2": 50,
"y2": 70
},
{
"type": "stroke",
"color": "red",
"lineWidth": 1
}
]
</textarea>
没有理由比首先使用您的应用程序更安全。
请记住,2D 画布 API 有一个Path2d接口,它可以极大地简化使用 SVG 语法构建复杂路径的过程:
const ctx = canvas.getContext('2d');
const user_input = "M10,10L35,35.5L80,35.53L50,70";
const path = new Path2D(user_input);
ctx.strokeStyle = "red";
ctx.stroke(path);
canvas { border: 1px solid }
<canvas id="canvas"></canvas>
如果您允许您的用户与 ImageData 交互,可能会有点困难。但是虽然更难,但您也可以很好地仅使用一组允许的算术运算来对这些对象执行。
唯一可能存在非常小的安全漏洞(实际上是更多的隐私漏洞)的部分是,如果您确实允许您的用户绘制外包图像,那么他们将能够知道谁访问了该图像,但这是任何网站的事情确实显示外包图像必须处理,并且没有简单的解决方案。
当然,零风险是一个神话,但是当某些浏览器在将“gimme-the-code”作为输入传递给 JSON.parse 时,可能会在整个网络上吐出用户的信用卡号,但你不会是一个责任。
推荐阅读
- javascript - 为什么 axios GET 调用不适用于基本身份验证?
- php - 字符串中仅以空格(或字符串的开头/结尾)为边界的标题大小写单词
- javascript - 删除创建的 div js
- applescript - Applescript - 递归遍历目录
- android - 为什么在打开 android studio 时显示“错误调用主方法”?
- android - 如何以编程方式注册网络连接更改接收器?
- reactjs - 如何将状态重置为 redux 商店中的初始状态?
- python - 如何刷新Mysql连接?
- bash - shell 脚本中的 notify-send 命令问题
- c# - UI层如何访问GetSystemService(AudioService)