作为前端开放的基础安全保证,caja 是目前比较合适的运行机制,包括前端运行环境以及后端编译环境,这次先整体介绍下 caja 在前端是如何屏蔽外部模块代码对整体应用的影响 (注意:官方文档较少,以下为自己理解,难免偏颇).
组成部分
1. 整体运行环境:隔离模块与宿主环境,并提供外部应用与模块的沟通机制.
2. 提供 ecmascript5 以及 dom2 的全平台兼容实现,注入到运行环境中。
es5 部分通过直接修改本地原生对象原型实现,运行时直接使用原生对象,这种做法值得推荐 。
其中比较重要的是:模拟实现 es5 中的属性描述符 ,结合后端编译,用户的所有读写操作都会经过 caja 前端运行环境监测,是 caja 安全保证的核心机制.
dom 兼容部分则并不是直接修改宿主 dom 原型 ,而是自行构造了一系列 javascript 实现的 dom 类,使用组合模式,将操作增强并委托到对应的原生节点,然后将这些 dom 类注入到模块运行环境。用户程序对 dom 节点的操作都要经过 caja 运行环境的转发,便于控制。
3. html/css parser ,包含了简易的 html/css parser,对用户的 html,css进行必要的过滤以及添加自定义规则,最常见的是
1. 防止 id 冲突,经过过滤后,每个 id 都改写为全局唯一的标志.
2. 拦截随意跳转,监控代码的可能跳出点( src , href ...).
3. 代码模块化机制,后端将用户代码编译为模块化单元,前端通过模块化机制加载并初始化用户代码。实现代码广泛采用了 promise .
运行机制
caja 中的每个模块表示为一段 html,css,javascript 的结合体,外部应用嵌入多个模块,caja 保证每个模块的独立性与安全性:
1.不能访问平台的相关特性.(window.location,cookie )
2.不能污染全局。( 全局变量,原生对象,宿主对象 )
3.节点操作限于模块内部.
运行过程中,caja 的整体运行环境会对每个将要加载的模块创建一个 runtime iframe,每个runtime iframe 都加载了 es5 兼容环境脚本以及 caja 模块机制脚本,由模块机制脚本加载对应的编译后用户模块代码并运行.
除了一个模块一个 runtime iframe,所有模块都共享一个 tame iframe ,该 tame iframe 加载 dom 兼容层脚本,caja 运行环境负责调用 tame iframe 为每个 runtime iframe 建立一个以模块根节点为 body 的虚假的 dom 环境,再将该环境融入到 runtime iframe 中,因此所有模块的 dom 节点类都是一样的,环境都是从tame iframe中产生的拥有不同 body 节点的不同实例.
一些讨论点
1. 由于 caja 中对所有的原生 dom 都组合包装了一层兼容实现,但最终运行都要转交给原生的 dom 节点,而不同模块间原生 dom 节点本来就共享一个宿主环境,因此 dom 兼容在所有模块间共享是合适的.
2. 每个模块都有自己的运行环境,存放虚假 dom 容器以及 es5 环境,通过每个模块运行在一个单独的 iframe,那么可以将该环境放在对应模块的 iframe 中,每个用户模块代码可以在自己的 iframe 内对一个统一环境变量进行操作,不需要模块间区分。
3.外部应用与模块间沟通通过 tame iframe 进行,外部应用数据 tame 后进入模块内执行,模块数据 untame 后回到外部应用,确保模块内都是包装过的内容,用户代码不能直接访问到原生数据.
示例
用户模块代码
<style type="text/css"> #xx { color: red !important; } </style> <span id='xx'></span> <script type="text/javascript"> document.getElementById("xx").innerHTML='wow!'; </script>
编译后代码
___.loadModule({
// 模块启动
'instantiate': function (___, IMPORTS___) {
// html 子模块
return ___.prepareModule({
'instantiate': function (___, IMPORTS___) {
var dis___ = IMPORTS___;
var moduleResult___;
moduleResult___ = ___.NO_RESULT; {
// html parser 必要修改
IMPORTS___.htmlEmitter___.emitStatic('\x3cspan id=\"id_2___\"\x3e\x3c/span\x3e');
}
return moduleResult___;
},
}).instantiate___(___, IMPORTS___),
// css 模块
___.prepareModule({
'instantiate': function (___, IMPORTS___) {
var dis___ = IMPORTS___;
var moduleResult___, el___, emitter___;
moduleResult___ = ___.NO_RESULT; {
// css parser 必要修改
IMPORTS___.emitCss___(['.', ' #xx-', ' {\n color: red !important\n}'].join(IMPORTS___.getIdClass___()));
emitter___ = IMPORTS___.htmlEmitter___;
el___ = emitter___.byId('id_2___');
emitter___.setAttr(el___, 'id', 'xx-' + IMPORTS___.getIdClass___());
el___ = emitter___.finish();
}
return moduleResult___;
},
}).instantiate___(___, IMPORTS___),
// javascript 子模块
___.prepareModule({
'instantiate': function (___, IMPORTS___) {
var dis___ = IMPORTS___;
var moduleResult___, x0___, x1___;
moduleResult___ = ___.NO_RESULT;
try {
{
// 从运行环境中取出必要数据运行
moduleResult___ = (x1___ = (x0___ = IMPORTS___.document_v___ ? IMPORTS___.document : ___.ri(IMPORTS___, 'document'), x0___.getElementById_m___ ? x0___.getElementById('xx') : x0___.m___('getElementById', ['xx'])), x1___.innerHTML_w___ === x1___ ? (x1___.innerHTML = 'wow!') : x1___.w___('innerHTML', 'wow!'));
}
} catch(ex___) {}
return moduleResult___;
},
上述代码运行于各自模块的 runtime iframe ,其中 IMPORTS__ 即为对应模块的运行环境,外部应用也可导入一部分 api 到此环境。
嵌入代码
最终动态嵌入到主应用的代码为:
<style type="text/css">.CajaGadget-0___ #xx-CajaGadget-0___ {
color: red !important
}</style>
<div title="<Untrusted Content Title>" class="caja_innerContainer___ CajaGadget-0___ vdoc-body___"><span id="xx-CajaGadget-0___">wow!</span></div>
可见经过 html/css parse 后避免了 id 冲突,而 js 加载后直接执行掉了,页面上并不存在。
错误处理
错误处理分两类:
1. 初始化错误
caja 会把模块初始化代码 try catch 起来交由用户配置的 onerror 函数处理,这样一个好处是,可以判断出当时出错时那个模块,例如:
frameGroup.makeES5Frame(document.getElementById("xx"), function (frame) { var onerror = frameGroup.tame(frameGroup.markFunction(function (message, source, lineNum) { console.log('初始化出错啦: ' + message + ' in source: "' + source + '" at line: ' + lineNum); return false; })); frame.url("yy.html") .run({ onerror: onerror }); });
2. 异步错误
异步错误是指初始化以外由事件或异步请求处理导致的错误,这种情况下实际上脱离了 caja 的工作范围,由于编译后程序在主窗口js引擎运行,那么错误会直接冒泡到主窗口的 window.onerror 事件处理器,而这时就区分不出来是哪个模块发生错误了:
window.οnerrοr=function(message, source, lineNum){ console.log('运行中出错啦: ' + message + ' in source: "' + source + '" at line: ' + lineNum); }; frameGroup.makeES5Frame(document.getElementById("xx"), function (frame) { var onerror = frameGroup.tame(frameGroup.markFunction(function (message, source, lineNum) { console.log('初始化出错啦: ' + message + ' in source: "' + source + '" at line: ' + lineNum); return false; })); frame.url("yy.html") .run({ onerror: onerror }); });
期望后期 caja 对所有的函数调用进行 try catch,这样才能得到错误的具体模块以及行号。
目前的一个暂时解决方法为,对于事件调用都异步操作提供定制 api,在这个 api 里对用户的调用进行封装
frame.url("x.html") .run({ onerror: onerror, KISSY: shared({ onerror: function (e) { console.log('x.html 运行中出错啦: ' + e.message); }, imports: frame.imports, context: document.getElementById("theGadget") }) });
在 share 中返回事件注册新的 api,并对 用户调用函数进行 try catch:
function shared(param) { function genWrapper() { function wrapper(e) { if (e.target) { e.target = tame(e.target); } if (e.relatedTarget) { e.relatedTarget = tame(e.relatedTarget); } if (e.currentTarget) { e.currentTarget = tame(e.currentTarget); } // 对用户真正的处理函数进行 try catch try { return wrapper.handle.call(this, e); } catch (e) { if (param.onerror) { param.onerror(e); } else { throw e; } } } return wrapper; } return frameGroup.tame({ Event: { add: frameGroup.markFunction(function (s, event, handle, scope) { var wrapper = genWrapper(); wrapper.handle = handle; handle.__event_tag = handle.__event_tag || []; var els = query(s); S.each(els, function (el) { handle.__event_tag.push({ fn: wrapper, el: el, scope: scope || el }); }); S.Event.on(els, event, wrapper, scope); });, } }); }
此时用户在模块代码里通过:
KISSY.Event.on('.xx',function(){ throw new Error('xx error!'); });
注册的事件处理函数里抛出的错误可被对应模块捕获而不会到顶层 window.onerror 。