haochuan9421 / blog

我的博客
67 stars 6 forks source link

如何保证你执行的 JS 就是你想执行的 JS? #13

Open haochuan9421 opened 3 years ago

haochuan9421 commented 3 years ago

背景

我网站执行的 JS 当然是我自己写的 JS 呀,难不成还有安全问题🤔?但想想,很多时候网站的 JS 是从第三方的 CDN 服务加载下来的,如果CDN服务器受到了攻击,JS 文件被篡改,就会带来安全风险,如何保证我们网站运行的这些脚本文件是未被修改的呢?

还有一种情况就是 html 中可能有一些 inline script,这些 JS 可能是在服务端渲染 html 模版的时候注入的,如果网络传输过程中被抓包篡改了怎么办?

伪代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <!-- inject-placeholader -->
    <div id="root"></div>
  </body>
</html>
router.get("/", async (ctx) => {
  const htmlTpl = await getHtmlTpl();
  const script = `window.__SOME_DATA__ = ${JSON.stringify(__SOME_DATA__)};`;
  ctx.type = "html";
  ctx.body = htmlTpl.replace("<!-- inject-placeholader -->", `<script>${script}</script>`);
});

先来看看传统软件是怎么保证用户获取到的内容是未篡改的。比如 linuxkit/linuxkit,他的发布页面会附上 Checksums

image

这些 hash 值是根据文件内容,通过固定的算法得到的,比如上面的 SHA256。这样获取到文件的用户用同样的算法做一次计算,如果得到的 hash 值和作者标明的一致,就可以验证软件没有被别人篡改过的。

回到前端,前端有没有类似的安全方案呢?答案是肯定的。

1. Subresource Integrity(SRI)

我们给 <script><link> 标签添加 integrity 属性,属性值是根据文件内容做 hash 算法得到的一个字符串。形如:

<link href="//somecdn.com/foo.css" rel="stylesheet" integrity="sha256-t7Z7PgokIxRooJ8azMRTqZZIdgaQX6ViGg3pn3pxZZs= sha384-KJi9xVfT8JzG/tFq6Dpgw6URtNE3WK83VaQOWpfHsVAN6Az5+AjliGZuSiiWd4ah" crossorigin="anonymous">

<script src="//somecdn.com/bar.js" integrity="sha256-XXVAhVe8STxZbyQhPOwZpZmx3X9iHnnrBPHUN/4vooc= sha384-438vOegRAvOckkDAIIIL8+k0JhRCRfY7Q2QXLjgFOHQbhyFK/YwGIDJBxYCdaHjA" crossorigin="anonymous"></script>

其中的 sha256sha384 是 hash 算法,而 sha256-sha384- 后面的部分是 hash code, 对于跨域的脚本请求,脚本服务器需要设置 CORS 响应头,允许跨域站点访问他的内容 Access-Control-Allow-Origin: *<script><link> 标签上也需要添加 crossorigin 属性,anonymous 代表请求不携带 cookie。

对于设置了 SRI 的脚本或样式,浏览器会对请求下载的文件内容做同样的 hash 算法,如果 hash code 不匹配,就会拒绝执行。 image

webpack 的项目可以通过 webpack-subresource-integrity 在打包时自动添加 Integrity 和 anonymous,配置如图:

image image

2. Content-Security-Policy (CSP)

对于一些行内脚本,我们可以通过设置 CSP 的 方式来校验。比如:

router.get("/", async (ctx) => {
  const htmlTpl = await getHtmlTpl();
  const script = `window.__SOME_DATA__ = ${JSON.stringify(__SOME_DATA__)};`;
  const hash = require("crypto").createHash("sha256").update(script).digest("base64");
  ctx.set("Content-Security-Policy", `script-src 'self' 'sha256-${hash}'`);
  ctx.type = "html";
  ctx.body = htmlTpl.replace("<!-- inject-placeholader -->", `<script>${script}</script>`);
});

上面的 CSP 设置保证了只有 hash 值和我们给定的 hash 值一致 inline-script 才会被执行,对于不符合的 inline-script,会抛出如下错误:

image

不过话说回来,既然算法是固定的,交付 hash code 的过程也是通过网络传输到前端的(html 内容,response header),如果有人能在网络传输的过程中修改响应,那么他把恶意代码做一次同样的算法,然后把 hash code 也改掉,还是无法避免不安全脚本的出现。这就要求我们要保证网络传输的安全。一方面我们需要给网站配置 https 避免明文传输,另一方面也要告知用户不要在访问网站的时候使用不可信代理。

补充

顺便说一下,保证内容可靠性的另一种常见做法。对接过微信公众号消息的同学可能有印象,微信的服务器会给我们的服务器推送消息,我们如何保证消息是微信的服务器发送过来的,而不是其他人伪造的呢?做法就是微信和我们自己的服务器都持有同一个 Token。微信发过来的消息中有一个签名 signature,这个签名就是由请求的内容和这个 Token 共同参与生成的。只要保证微信侧和我们自己的服务持有同一个Token,对同样的内容,做同样算法的签名就可以,如果最终得到的签名一致就说明请求确实是微信发给我们的。可以参考我之前写过的 Node.js 对接微信消息通知的 [ 示例

image

这种做法不同于上面的一点是双方都保留了一个不为第三方所知的 Token,只要 Token 没泄露,即使别人知道你们的签名算法是啥,也无法伪造出一个签名。这种方式并不适用于前端,因为前端无法不通过网络把一个 “Token” 预先埋在所有用户的系统里。

参考链接

https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity https://developer.mozilla.org/zh-CN/docs/Web/HTML/CORS_settings_attributes https://github.com/waysact/webpack-subresource-integrity https://webpack.js.org/configuration/output/#outputcrossoriginloading https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src