本文由云 + 社区发表
作者: ivweb villainthr
市面上现在流行两种沙箱模式, 一种是使用 iframe, 还有一种是直接在页面上使用 new Function + eval 进行执行. 殊途同归, 主要还是防止一些 Hacker 们 吃饱了没事干, 收别人钱来 Hack 你的网站. 一般情况, 我们的代码量有 60% 业务 + 40% 安全. 剩下的就看天意了. 接下来, 我们来一步一步分析, 如果做到在前端的沙箱. 文末 看俺有没有心情放一个彩蛋吧.
直接嵌套
这种方式说起来并不是什么特别好的点子, 因为需要花费比较多的精力在安全性上.
eval 执行
最简单的方式, 就是使用 eval 进行代码的执行 eval('console.log("a simple script");');
但, 如果你是直接这么使用的话, congraduations... do die... 因为, eval 的特性是如果当前域里面没有, 则会向上遍历. 一直到最顶层的 global scope 比如 Windows. 以及, 他还可以访问 closure 内的变量. 看 demo:
- function Auth(username)
- {
- var password = "trustno1";
- this.eval = function(name) { return eval(name) } // 相当于直接 this.name
- }
- auth = new Auth("Mulder")
- console.log(auth.eval("username")); // will print "Mulder"
- console.log(auth.eval("password")); // will print "trustno1"
那有没有什么办法可以解决 eval 这个特性呢? 答: 没有. 除非你不用 ok, 那我就不用. 我们这里就可以使用 new Function(..args,bodyStr) 来代替 eval.
new Function
new Function 就是用来, 放回一个 function obj 的. 用法参考: new Function. 所以, 上面的代码, 放在 new Function 中, 可以写为: new Function('console.log("a simple script");')();
这样做在安全性上和 eval 没有多大的差别, 不过, 他不能访问 closure 的变量, 即通过 this 来调用, 而且他的性能比 eval 要好很多. 那有没有办法解决 global var 的办法呢? 有啊... 只是有点复杂先用 with, 在用 Proxy
with
with 这个特性, 也算是一个比较鸡肋的, 他和 eval 并列为 JS 两大 SB 特性. 不说无用, bug 还多, 安全性就没谁了... 但是, with 的套路总是有人喜欢的. 在这里, 我们就需要使用到他的特性. 因为, 在 with 的 scope 里面, 所有的变量都会先从 with 定义的 Obj 上查找一遍.
- var a = {
- c:1
- }
- var c =2;
- with(a){
- console.log(c); // 等价于 c.a
- }
所以, 第一步改写上面的 new Function(), 将里面变量的获取途径控制在自己的手里.
- function compileCode (src) {
- src = 'with (sandbox) {' + src + '}'
- return new Function('sandbox', src)
- }
这样, 所有的内容多会从 sandbox 这个 str 上面获取, 但是找不到的 var 则又会向上进行搜索. 为了解决这个问题, 则需要使用: proxy
proxy
es6 提供的 Proxy 特性, 说起来也是蛮牛逼的. 可以将获取对象上的所有方式改写. 具体用法可以参考: 超好用的 proxy. 这里, 我们只要将 has 给换掉即可. 有的就好, 没有的就返回 undefined
- function compileCode (src) {
- src = 'with (sandbox) {' + src + '}'
- const code = new Function('sandbox', src)
- return function (sandbox) {
- const sandboxProxy = new Proxy(sandbox, {has})
- return code(sandboxProxy)
- }
- }
- // 相当于检查 获取的变量是否在里面 like: 'in'
- function has (target, key) {
- return true
- }
- compileCode('log(name)')(console);
这样的话, 就能完美的解决掉 向上查找变量的烦恼了. 另外一些, 大神, 发现在新的 ECMA 里面, 有些方法是不会被 with scope 影响的. 这里, 主要是通过 Symbol.unscopables 这个特性来检测的. 比如:
- Object.keys(Array.prototype[Symbol.unscopables]);
- // ["copyWithin", "entries", "fill", "find", "findIndex",
- // "includes", "keys", "values"]
不过, 经过本人测试发现也只有 Array.prototype 上面带有这个属性... 尴尬... 所以, 一般而言, 我们可以加上 Symbol.unscopables, 也可以不加.
- // 还是加一下吧
- function compileCode (src) {
- src = 'with (sandbox) {' + src + '}'
- const code = new Function('sandbox', src)
- return function (sandbox) {
- const sandboxProxy = new Proxy(sandbox, {has, get})
- return code(sandboxProxy)
- }
- }
- function has (target, key) {
- return true
- }
- function get (target, key) {
- // 这样, 访问 Array 里面的 like, includes 之类的方法, 就可以保证安全... 算了, 就当我没说, 真的没啥用...
- if (key === Symbol.unscopables) return undefined
- return target[key]
- }
现在, 基本上就可以宣告你的代码是 99.999% 的 5 位安全数.(反正不是 100% 就行)
设置缓存
如果上代码, 每次编译一次 code 时, 都会实例一次 Proxy, 这样做会比较损性能. 所以, 我们这里, 可以使用 closure 来进行缓存. 上面生成 proxy 代码, 改写为:
- function compileCode(src) {
- src = 'with (sandbox) {' + src + '}'
- const code = new Function('sandbox', src)
- function has(target, key) {
- return true
- }
- function get(target, key) {
- if (key === Symbol.unscopables) return undefined
- return target[key]
- }
- return (function() {
- var _sandbox, sandboxProxy;
- return function(sandbox) {
- if (sandbox !== _sandbox) {
- _sandbox = sandbox;
- sandboxProxy = new Proxy(sandbox, { has, get })
- }
- return code(sandboxProxy)
- }
- })()
- }
不过上面, 这样的缓存机制有个弊端, 就是不能存储多个 proxy. 不过, 你可以使用 Array 来解决, 或者更好的使用 Map. 这里, 我们两个都不用, 用 WeakMap 来解决这个 problem. WeakMap 主要的问题在于, 他可以完美的实现, 内部变量和外部的内容的统一. WeakMap 最大的特点在于, 他存储的值是不会被垃圾回收机制关注的. 说白了, WeakMap 引用变量的次数是不会算在引用垃圾回收机制里, 而且, 如果 WeakMap 存储的值在外部被垃圾回收装置回收了, WeakMap 里面的值, 也会被删除 -- 同步效果. 所以, 毫无意外, WeakMap 是我们最好的一个 tricky. 则, 代码可以写为:
- const sandboxProxies = new WeakMap()
- function compileCode(src) {
- src = 'with (sandbox) {' + src + '}'
- const code = new Function('sandbox', src)
- function has(target, key) {
- return true
- }
- function get(target, key) {
- if (key === Symbol.unscopables) return undefined
- return target[key]
- }
- return function(sandbox) {
- if (!sandboxProxies.has(sandbox)) {
- const sandboxProxy = new Proxy(sandbox, { has, get })
- sandboxProxies.set(sandbox, sandboxProxy)
- }
- return code(sandboxProxies.get(sandbox))
- }
- }
差不多了, 如果不嫌写的丑, 可以直接拿去用.(如果出事, 纯属巧合, 本人概不负责).
接着, 我们来看一下, 如果使用 iframe, 来实现代码的编译. 这里, Jsfiddle https://jsfiddle.net/ 就是使用这种办法.
iframe 嵌套
最简单的方式就是, 使用 sandbox 属性. 该属性可以说是真正的沙盒... 把 sandbox 加载 iframe 里面, 那么, 你这个 iframe 基本上就是个标签而已... 而且支持性也挺棒的, 比如 IE10. <iframe sandbox src="..."></iframe>
这样已添加, 那么下面的事, 你都不可以做了:
script 脚本不能执行
不能发送 Ajax 请求
不能使用本地存储, 即 localStorage,cookie 等
不能创建新的弹窗和 Windows, 比如 Windows.open or target="_blank"
不能发送表单
不能加载额外插件比如 flash 等
不能执行自动播放的 tricky. 比如: autofocused, autoplay
看到这里, 我也是醉了. 好好的一个 iframe, 你这样是不是有点过分了. 不过, 你可以放宽一点权限. 在 sandbox 里面进行一些简单设置 <iframe sandbox="allow-same-origin" src="..."></iframe>
常用的配置项有:
配置 | 效果 |
---|---|
allow-forms | 允许进行提交表单 |
allow-scripts | 运行执行脚本 |
allow-same-origin | 允许同域请求, 比如 ajax,storage |
allow-top-navigation | 允许 iframe 能够主导 window.top 进行页面跳转 |
allow-popups | 允许 iframe 中弹出新窗口, 比如, window.open,target="_blank" |
allow-pointer-lock | 在 iframe 中可以锁定鼠标,主要和鼠标锁定有关 |
可以通过在 sandbox 里, 添加允许进行的权限. <iframe sandbox="allow-forms allow-same-origin allow-scripts" src="..."></iframe>
这样, 就可以保证 JS 脚本的执行, 但是禁止 iframe 里的 JavaScript 执行 top.location = self.location. 更多详细的内容, 请参考: please call me HR.
接下来, 我们来具体讲解, 如果使用 iframe 来 code evaluation. 里面的原理, 还是用到了 eval.
iframe 脚本执行
上面说到, 我们需要使用 eval 进行方法的执行, 所以, 需要在 iframe 上面添加上, allow-scripts 的属性.(当然, 你也可以使用 new Function, 这个随你...) 这里的框架是使用 postMessage+eval. 一个用来通信, 一个用来执行. 先看代码:
<!-- frame.html -->
- <!DOCTYPE HTML>
- <HTML>
- <head>
- <title>
- Evalbox's Frame
- </title>
- <script>
- Windows.addEventListener('message',
- function(e) {
- // 相当于 Windows.top.currentWindow.
- var mainWindow = e.source;
- var result = '';
- try {
- result = eval(e.data);
- } catch(e) {
- result = 'eval() threw an exception.';
- }
- // e.origin 就是原来 Windows 的 url
- mainWindow.postMessage(result, e.origin);
- });
- </script>
- </head>
- </HTML>
这里顺便插播一下关于 postMessage 的相关知识点.
postMessage 讲解
postMessage 主要做的事情有三个:
页面和其打开的新窗口的数据传递
多窗口之间消息传递
页面与嵌套的 iframe 消息传递
具体的格式为: otherWindow.postMessage(message, targetOrigin, [transfer]);
message 是传递的信息, targetOrigin 指定的窗口内容, transfer 取值为 Boolean 表示是否可以用来对 obj 进行序列化, 相当于 JSON.stringify, 不过一般情况下传 obj 时, 会自己先使用 JSON 进行 seq 一遍. 具体说一下 targetOrigin. targetOrigin 的写入格式一般为 URI, 即, protocol+host. 另外, 也可以写为 *. 用来表示 传到任意的标签页中. 另外, 就是接受端的参数. 接受传递的信息, 一般是使用 Windows 监听 message 事件.
- Windows.addEventListener("message", receiveMessage, false);
- function receiveMessage(event)
- {
- var origin = event.origin || event.originalEvent.origin; // For Chrome, the origin property is in the event.originalEvent object.
- if (origin !== "http://example.org:8080")
- return;
- // ...
- }
event 里面, 会带上 3 个参数:
data: 传递过来的数据. e.data
origin: 发送信息的 URL, 比如: https://example.org/
source: 发送信息的源页面的 Windows 对象. 我们实际上只能从上面获取信息.
该 API 常常用在 Windows 和 iframe 的信息交流当中. 现在, 我们回到上面的内容.
<!-- frame.html -->
- <!DOCTYPE HTML>
- <HTML>
- <head>
- <title>
- Evalbox's Frame
- </title>
- <script>
- Windows.addEventListener('message',
- function(e) {
- // 相当于 Windows.top.currentWindow.
- var mainWindow = e.source;
- var result = '';
- try {
- result = eval(e.data);
- } catch(e) {
- result = 'eval() threw an exception.';
- }
- // e.origin 就是原来 Windows 的 url
- mainWindow.postMessage(result, e.origin);
- });
- </script>
- </head>
- </HTML>
iframe 里面, 已经做好文档的监听, 然后, 我们现在需要进行内容的发送. 直接在 index.HTML 写入:
- // HTML 部分
- <textarea id='code'></textarea>
- <button id='safe'>eval() in a sandboxed frame.</button>
- // 设置基本的安全特性
- <iframe sandbox='allow-scripts'
- id='sandboxed'
- src='frame.html'></iframe>
- // JS 部分
- function evaluate() {
- var frame = document.getElementById('sandboxed');
- var code = document.getElementById('code').value;
- frame.contentWindow.postMessage(code, '/'); // 只想同源的标签页发送
- }
- document.getElementById('safe').addEventListener('click', evaluate);
- // 同时设置接受部分
- Windows.addEventListener('message',
- function (e) {
- var frame = document.getElementById('sandboxed');
- // 进行信息来源的验证
- if (e.origin === "null" && e.source === frame.contentWindow)
- alert('Result:' + e.data);
- });
实际 demo 可以参考: H5 ROCK
常用的两种沙箱模式这里差不多讲解完了. 开头说了文末有个彩蛋, 这个彩蛋就是使用 Node.JS 来做一下沙箱. 比如像 牛客网的代码验证, 就是放在后端去做代码的沙箱验证.
彩蛋 --Node.JS 沙箱
使用 Node.JS 的沙箱很简单, 就是使用 Node.JS 提供的 VM Module 即可. 直接看代码吧:
- const vm = require('vm');
- const sandbox = {
- a: 1, b: 1
- };
- const script= new vm.Script('a + b');
- const context = new vm.createContext(sandbox);
- script.runInContext(context);
在 vm 构建出来的 sandbox 里面, 没有任何可以访问的全局变量. 除了基本的 syntax.
来源: https://www.cnblogs.com/qcloud1001/p/10491411.html