说到跨站资源监控,首先会联想到『Content Security Policy』。既然 CSP 好用,我们何必自己再搞一套呢。那就先来吐槽下 CSP 的缺陷。
目前的 CSP
日志不详细
用过 CSP 的都很郁闷,上报的只有违规的站点名,却没有具体路径。这是缺陷,还是特意的设计?
显然,CSP 是为安全定制的,里面的规范自然要严格制定,否则就会带来新的安全问题。如果支持详细路径的上报,那又会引出什么问题?
由于 CSP 会上报所有的请求,甚至包括重定向的,因此可以用来探测重定向后的地址。假如已登录的用户访问 login.xx.com 会重定向到 xx.com/username,那么攻击者设计一个只允许重定向前的规则的页面,用户访问后,重定向后的 URL 就会当做违规地址上报给攻击者,这其中就包括了用户名。
如果支持详细路径的上报,这简直就是灾难,就用来探测的用户隐私信息了。事实上目前只上报主机名,都能进行一些利用,例如这篇 Using Content-Security-Policy for Evil。
不过新的规范总是在改进,未来也许只上报重定向前的 URL。但在这之前,我们只能接受这些鸡肋的上报日志。
规则不灵活
CSP 目前只支持白名单列表,这多少有些死板。
更糟的是,不同规则之间无法继承和共享。例如默认有个 default-src
规则,但其他的规则会覆盖它,而不是继承它。这就导致各个规则之间,出现很多的重复,使得整个字符串变的冗长。
无法和页面交互
CSP 的监控和上报,是在浏览器后台自动处理的,没有提供一个事件供页面进行交互。
这样就只能使用统一方式强制处理了,而无法交给页面脚本,更好的来自定义处理。
上报方式不可控
如果处理方式有多种选择,那么统一处理也无可厚非。
但事实上 CSP 的上报方式及格式,没有任何可选余地。只能使用 POST + JSON 的方式提交,并且其中的字段十分累赘,甚至把规则里的白名单列表也发上来了。
此外,也无法设定一个缓存时间,控制重复上报的间隔。在配置白名单遗漏时,会出现大量的误报,严重消耗资源。
浪费带宽
在较新的 Chrome 里,能够使用 meta 标签在前端页面定义 CSP 规则,但其他浏览器目前仍不支持。
为了能够统一,大多仍使用 HTTP 头部输入的方式。由于规则通常都很长,导致每次页面访问,都会额外增加数百字节。
维护繁琐
如果是通过 Web 服务开启的,那么每次调整策略,都得修改配置甚至重启服务,很是麻烦。
兼容性不高
目前只有高版本的浏览器支持,而 IE 系列的则几乎都没能很好的支持。
如果某些攻击只争对低版本的浏览器,那么很有可能出现大量遗漏。
模拟的 CSP
原理
事实上在 CSP 出现的好几年前,就有一个能够监控跨站资源的方案,下面就来分享下。
写过 JS 的都知道,如果需要给大量元素监听事件,无需对每个元素上都进行绑定,只要监听它们的容器即可。当具体的事件冒泡到容器上,通过 event.target 即可获知是哪个元素产生的。
脚本、图片、框架等元素加载完成时,都会产生 onload 事件;而所有元素都位于『文档』这个顶级容器。因此我们监听 document 的 onload 事件,即可获知所有加载资源的元素。
不过 onload 这个事件比较特殊,无法通过冒泡的方式来监听。但在 DOM-3 标准模型里,事件还有一个『捕获』的概念,这也是为什么 addEventListener 有第三个参数的原因。
我们通过事件捕获机制,将其拿下,从而监控文档级别的全局 onload 事件。
<script>
document.addEventListener('load', function(e) {
console.log(e.target);
}, true);
</script>
<script src="https://libs.baidu.com/jquery/1.9.0/jquery.js"></script>
<iframe src="https://www.baidu.com/"></iframe>
类似的,如果资源加载失败,会触发 onerror 事件。我们也可同时将其捕获,跟踪那些暂时不可用的跨站资源。
优势
通过脚本的方式,就可以更灵活的处理问题了。规则的黑白名单,上报方式、格式等等,都可以自己来定义。最重要的是,我们能够获得详细的违规 URL 了!
相比后端配置,前端脚本更新维护起来容易的多。而且得益于浏览器缓存,无需每次更新配置,节省很多资源。
增强
也许你已经发现了,这只能实现 CSP 的 Report-Only 功能,根本无法进行拦截。而且其他的内联事件、网络通信等也没有涉及。
不过本文的主题已经说了,只是监控跨站资源而已,并不拦截。事实上,如果能够做到及时发现问题,就很不错了。
如果非得通过 JS 来实现拦截功能,可以参考之前的『XSS 前端防火墙系列』:
虽然可以更严格的防护,但实现起来更臃肿,性能开销也更大。要是拦截了正常的业务功能,造成的损失会更大。
而使用如今这个小技巧,只需几行代码即可实现,性能消耗忽略不计。
缺陷
当然,那么简单的方案肯定无法全面。
由于我们只监听文档容器,有些还未加入到文档的元素产生的事件,我们就无法捕获到了。最典型的就是:
new Image().src = '...'
虽然创建的 HTMLImageElement 对象具有 onload 事件,但是此时还只是一个离屏元素,事件就无法对外传播了。
如果非得解决,只能通过函数钩子的方式监控 URL。
此外,IE9 以下的浏览器不支持 DOM-3,而 attachEvent 是无法设置捕获的,因此我们还需一个后备方案。毕竟国内低版本 IE 用户仍有不少,即使能实现部分功能,也胜于无。
后备
事实上,即使主流浏览器,有些特殊元素并没有 onload 事件,例如 Flash 插件。
为了弥补这些不足,同时尽可能保持简单高效,我们使用定时轮询的方式,对特定元素进行扫描。这里使用一个大家都知道,但未必都清楚的方法:document.getElementsByTagName。
这个功能都知道,但返回的类型或许很少琢磨。他返回的并不是一个 Array,也不是 NodeList,而是 HTMLCollection。
在 W3C 规范描述中,有一个显眼的词 『live』,已经道出了这个接口的独特之处——它是一个动态的集合,能随着容器内元素的增减而变化。
因此,我们事先映射出文档容器内的元素,之后即可随时查询集合了。
<button id="btn">Load Script</button>
<div id="stat"></div>
<script>
function log(str) {
var line = document.createElement('div');
line.innerHTML = str;
stat.appendChild(line);
}
// 这是个动态集合,只需查询一次即可!
var colScript = document.getElementsByTagName('script');
setInterval(function() {
var display = [];
// 可以访问到最新的记录
for (var i = colScript.length - 1; i >= 0; i--) {
var el = colScript[i];
if (el.src) {
// check url
display.push(el.src);
}
}
log('num: ' + display.length + ' list:' + display.join(',') );
}, 1000);
// [test] load script
btn.onclick = function() {
var el = document.createElement('script');
el.src = 'http://libs.baidu.com/jquery/1.9.0/jquery.js';
document.body.appendChild(el);
}
</script>
除了 SCRIPT,我们还可以监控其他存在隐患的元素,例如:EMBED,OBJECT,IFRAME 等等。这样即使是低版本的 IE 用户,也能参与预警上报了,比起完全没有好的多。
当然,这种简单方法也很容易被绕过。如果脚本删除了自身元素,那么我们就无法跟踪到了。而脚本一旦运行就已在内存里,即使元素节点被移除,仍然能继续运行。
不过,对于一般的情况也足够应对了。通常运行商的广告劫持,大多都很落后。即使要进行后期对抗,也可以利用之前的前端防火墙,使用严格的方案。
扩展信息
见过 CSP 日志的大多都会很困惑,出现的这些违规资源,究竟位于页面何处。由于没有确切的细节,给排查工作带来很大困难。
毕竟,绝大多数的上报问题,都是无法复现的。它们要么是运行商的广告,或者是浏览器插件。难很通过这些日志,来定位问题所在。
既然如今使用自己的脚本来实现,理应带上一些有意义的信息。除了资源类型和详细 URL,我们还需要一个能够定位问题所在的参数——DOM 路径。
将每层元素的 #id 和 .class 跟随标签名,得到一个标准的 CSS 选择器。通过它,即可非常容易的定位到违规元素,同时也能用于统计和分析。
例如出现顶级元素就是 SCRIPT
的,显然这是一个被插入到 <html>
之外的脚本,很有可能就是运营商注入的广告脚本。
例如出现 HTML > BODY > ... > DIV.editor-post > SCRIPT
这样帖子容器里的脚本,那么极有可能是出现 XSS 了。正常情况下,用户内容区域并不会出现脚本元素。
通过详细的上报日志不断学习,后端即可越来越精准的分析出问题所在。
而这些,目前的 CSP 难以实现。
上报方式
吸取 CSP 报告的不足之处,我们使用更灵活的方式。
对于运营商广告那样的跨站资源,反复上报同样的信息,是毫无意义的,只会浪费带宽资源。对于同一类警告,一定时间内上报一次就后够了。
利用 URL、DOM 路径等信息,可以更容易的将日志进行客户端去重。通过本地存储的记录,就不必每次都上报了,在本地记录违规的次数即可。
对于不严重的警告,本地也可以累计到一定的数量再上报。这样多条日志一次发送,即可大幅减轻后端压力。甚至还可以考虑压缩内容,节省网络带宽。
只有让前端进行负载维护,才不至于大规模部署的场合下,接收端被海量的误报日志拖垮。
总结
尽管 CSP 的初衷很美好,但到如今,仍只是能用,并没有做到好用。因此,实际面临的问题,还是得靠自己来解决。
当然,标准的制定本来就是受益于大众的,我们也希望 CSP 标准能发展的越来越好用。