学会用CSP,黑客都怕你
前言
XSS就是跨站脚本攻击,攻击者通过注入脚本盗取用户信息;常见的防御措施是:HTML转义、cookie设置httpOnly、设置CSP「内容安全策略」、设置SRI「子资源完整性」;以上是一段八股文,光掌握八股文是不够的,要在实际项目中使用到这些安全防御手段那是需要一番功夫的,下面我们就以一个案例为引子讲一讲如何使用CSP保护页面安全
案例
这是一个特殊的场景:我们在做性能优化的过程中,有时候会用LS缓存一些CSS、JS资源,优化资源加载时间;我们需要使用webpack将当前脚本的版本通过meta标签注入到HTML文档中,像这样:
<meta id="versionStore" content="0.0.1" />
然后通过ajax请求获取到脚本内容,并创建内联脚本添加到页面上,同时将脚本内容缓存到LS;第二次加载时判断版本号,如果版本号一致则直接使用缓存内容,如果不一致则重新获取脚本:
let scriptPath = "test/server/path";
let script = document.createElement("script");
let newVersion = document.getElementById("versionStore").getAttribute("content");
let oldVersion = localStorage.getItem("version");
function _updateLocalStorage(path, value) {
localStorage.setItem("version", newVersion);
localStorage.setItem(path, value);
}
// 比较版本号大小
// 1.0.1 > 0.0.2
// 返回值如果a>b 返回true
function isBiggerVersion(a = "", b = "") {
const aArr = a.split(".");
const bArr = b.split(".");
// 只比较索引相同的元素
const aLen = aArr.length;
for (let i = 0; i < aLen; i++) {
if (aArr[i] === bArr[i]) {
continue;
}
if (aArr[i] > bArr[i]) {
return true;
} else if (aArr[i] < bArr[i]) {
return false;
}
}
// 来到这里说aArr比较完了还没有结果,说明a b 版本相等
return false;
}
// 如果版本更新并且没有缓存,那么需要请求js
if (isBiggerVersion(newVersion, oldVersion || "0.0.0")) {
const ajax = new XMLHttpRequest();
ajax.open("GET", `${scriptPath}main.${newVersion}.js`);
ajax.setRequestHeader("content-type", "text");
ajax.onreadystatechange = function () {
if (this.status === 200 && this.readyState === 4) {
script.innerHTML = this.response;
document.body.appendChild(script);
_updateLocalStorage(scriptPath, this.response);
}
};
ajax.send();
} else {
script.innerHTML = localStorage.getItem(scriptPath);
document.body.appendChild(script);
}
这一段脚本可以正常运行并缓存,但是如果黑客通过评论或者富文本或者其他手段注入一段HTML文档改变了LS的内容:
<img src="" onerror="localStorage.setItem('test/server/path/','alert(document.cookie)');" alt="" />
这个时候就能够打印出cookie,当然黑客不会这么做,他们会加载一个恶意脚本,然后把cookie传过去,这样就可以成功地盗取用户信息了
这个情况的XSS有很多常规手段可以预防:
- HTML转义,防止恶意的标签被引入
- Cookie安全策略设置,比如httpOnly
- 使用Session代替Cookie
还有一些特殊手段:
- SRI:子资源的完整性,利用加密秘钥校验源文件是否被恶意篡改
- CSP:内容安全策略,类似于白名单,本文重点关注这种方式
CSP兼容性
在用之前我们要看看它的兼容性,不然到头来兼容性不好,一天就白干了:
兼容性没有问题,下面我们来看一看CSP可以设置哪些属性:
CSP的属性
属性 | 含义 |
---|---|
default-src | 兜底的白名单,一般设置为'self',也就是说加载同源的脚本 |
script-src | 脚本白名单,一般可以设置为CDN域名 |
img-src | 图片加载白名单,设置为CDN域名 |
style-src | 样式白名单 |
child-src | iframe脚本白名单 |
worker-src | worker白名单 |
接下来问题来了,怎么设置CSP?其实很简单,有两种方式可以设置CSP:
- meta标签
- web server设置响应头
通过meta设置CSP
通过meta标签先设置一个兜底的default-src
试试:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />
可以发现外部脚本和内联脚本都被禁止掉了,这样的话我们需要分别针对它们设置白名单;
CSP各个属性值之间用分号隔开,设置外部脚本CSP相对比较容易「http://127.0.0.1:8080替换成你需要加载的CDN」:
<meta http-equiv="Content-Security-Policy" content="default-src 'self';script-src http://127.0.0.1:8080" />
注意:self这种内置的值需要带上双引号,而CDN地址不需要带双引号
这样CDN的资源就成功加载了,接下来看一看如何加载内联脚本;内联脚本的加载有几种策略:
内联脚本策略一:unsafe-inline
直接设置unsafe-inline
,其他的什么也不用做,可以看到控制台不报错了,但是这种方式很危险;
禁止内联样式和内联脚本是CSP的一大优势,使用unsafe-inline将无法发挥其优势,依旧无法阻挡XSS攻击
举个例子:比如我们有个APP,需要读取url上的参数,这个时候把url上的参数改为一个script脚本,例如/app?name=<script src="eval.js"></script>
,这个时候如果我们读取name参数并试图展示到页面上,此时就加载了eval.js脚本,为了避免这种情况的发生我们尽量不要使用unsafe-inline
关键字
内联脚本策略二:使用hash戳
既然不推荐使用unsafe-inline
关键字,那么我们试试策略二:使用hash戳;我们根据控制台的提示设置浏览器提供给我们的hash戳,发现hash戳并不生效:
这种方式还有一个致命缺点:当脚本发生改变之后hash戳也需要动态更新,这对于我们发版很不友好;直接放弃它!
内联脚本策略三:设置脚本id
设置脚本idnonce-id
,然后script标签上添加nonce
属性指向同一个值,如果值不相同则会禁止加载;所有的脚本都可以共享一个nonce-id
:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self';script-src http://127.0.0.1:8080 nonce-1234567"
/>
<script nonce="1234567">
console.log("success");
</script>
我们可以在每次发版时通过工程化手段生成一个随机值插入到每一个脚本的属性上去
图片和样式
图片一般都是存储在OSS上,以CDN引入,因此只需要设置CDN CSP就可以了:img-src https://cdn.xxx
一般情况下内联样式会被禁止,但是通过js设置的样式不会被禁止:
<div style="display: block"></div>
下面的代码不会报错,可以正常执行:
<div id="container"></div>
<script>
const container = document.getElementById("container");
if (container) {
container.style.display = "block";
}
</script>
因此对于内联样式我们有两种解决方案,第一种在代码中禁止内联样式,全部使用外链或者标签,style标签上带有CSP随机数,第二种方式直接设置style-src 'unsafe-inline'
看了这么多发现设置CSP是多么麻烦啊!别慌,下面看看终极解决方案:
终极解决方案:webpack插件
上面讲了这么多属性,其实最后都需要动态加入到页面上去,因此我们必须使用工程化手段修改HTML文件,动态插入meta标签,并且给所有script加上nonce属性,这样的插件有很多,我们这里只分析其中一个插件:@melloware/csp-webpack-plugin
,整个源码有421行,我们仔细分析分析:
首先引入外部依赖:
外部依赖 | |
---|---|
cheerio | Nodejs中的jQ |
crypto | 加密 |
lodash/uniq | 去重 |
lodash/compact | 去掉数组中的这些值:false , null , 0 , "" , undefined , and NaN |
lodash/flatten | 扁平化 |
lodash/isFunction | 函数类型判断 |
webpack-subresource-integrity | 子资源完整性 |
webpack-inject-plugin | 动态给每一个bundle插入代码 |
在HtmlWebpackPlugin
的beforeAssetTagGeneration
阶段合并参数,并返回给compilation对象return compileCb(null, htmlPluginData);
在HtmlWebpackPlugin
的beforeEmit
阶段,为每一个script和style、link创建nonce,最后把所有nonce收集起来返回合并的meta content
通过webserver设置CSP
看了上面设置CSP的流程,还想尝试在webserver中设置CSP吗?webserver需要手动设置每一条策略,并且当版本更新有新增style或者脚本时还需要添加策略,相当麻烦;当然可以尝试写一个shell脚本根据webpack打包结果的meta content生成对应的nginx配置,每次上线前执行一下;这里就不过多分析了;
后记
我们由一个性能优化的案例引申到了XSS攻击,而预防XSS攻击一个行之有效的方案就是设置CSP;设置CSP相当困难,因为CSP本身阻止了内联脚本、外部脚本、内联样式、外部样式表、外部图片、外部资源加载,因此我们需要对这些内容分别设置白名单,这个时候webpack插件闪亮登场,解决了我们的问题;
在这个过程中,我们不仅涉及到了性能优化、前端安全还涉及到了工程化,所以日常工作中需要我们把这些知识点串联起来,这样才具备快速解决问题的能力;
本文涉及到的源码:github.com/missop/blog…
Tips: 可以尝试使用LiveServer打开index.html,然后用http-server打开test.js,index.html会加载跨域脚本test.js
参考文献
转载自:https://juejin.cn/post/7221147640490098745