一次真实项目中的安全加固实战经验分享

Dev · 泽睿 工具 阅读 1,231
赞 14 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

这个项目是个老后台系统,一开始就是个简单的 Vue + Element UI 搭的管理页,用户量不大,但最近被安全团队扫出一堆问题:XSS、CSRF、敏感信息泄露……直接被打了个中高危。负责人找到我说“得加固一下”,我第一反应是:这种小项目还能有啥大问题?结果一查审计报告,好家伙,光是未过滤的 innerHTML 就有七八处。

一次真实项目中的安全加固实战经验分享

我们没时间重构整套权限体系,所以目标很明确:在不改架构的前提下,快速堵住已知漏洞。最后决定从三块入手:输入输出过滤、接口防护、资源访问控制。核心方案是:CSP + 输入 sanitizer + token 双校验。CSP 是重头戏,也是后来最让我头疼的部分。

最大的坑:CSP 引入后页面直接白屏

我一开始挺乐观,觉得 CSP 加个 header 就完事了。于是在 Nginx 配置里加了:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';";

本地一测,页面直接白屏。F12 看了一堆报错:

Refused to load script from 'https://jztheme.com/static/js/chunk-vendors.js' because it violates the following Content Security Policy directive: "script-src 'self'"

哦对,部署时静态资源走的是 CDN,域名和主站不一样,’self’ 不包括 CDN。改!

add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://jztheme.com; style-src 'self' https://jztheme.com 'unsafe-inline'; img-src 'self' https://jztheme.com;";

这下能加载了,但控制台还是报警:

Refused to execute inline script because it violates CSP

翻代码发现,项目里有几个地方用了内联 script,比如动态生成图表配置的那段逻辑。这种 inline 脚本必须干掉,要么提出来,要么加 nonce。

提出来工作量太大,临时方案是加 nonce。思路是:服务端生成一个随机字符串,注入到 HTML 和 CSP 头里,只有带这个 nonce 的 script 才能执行。

Node.js 服务端代码改了一下:

const uuid = require('uuid');

app.get('/index.html', (req, res) => {
  const nonce = uuid.v4().replace(/-/g, '');
  const cspHeader = 
    default-src 'self';
    script-src 'self' https://jztheme.com 'nonce-${nonce}';
    style-src 'self' https://jztheme.com 'unsafe-inline';
    img-src 'self' https://jztheme.com data:;
    connect-src 'self' https://jztheme.com;
    font-src 'self' https://jztheme.com;
    object-src 'none';
    base-uri 'self';
  .replace(/s+/g, ' ').trim();

  res.set('Content-Security-Policy', cspHeader);

  const html = fs.readFileSync('./public/index.html', 'utf-8');
  const injectedHtml = html.replace(
    /<script/g,
    &lt;script nonce=&quot;${nonce}&quot;  
  );

  res.send(injectedHtml);
});

前端所有 inline script 都加上 nonce 属性:

<script nonce="abc123def">
  // 初始化配置
  window.APP_CONFIG = { api: '/api', env: 'prod' };
</script>

这下终于不报错了。但很快发现新问题:Webpack 打包出来的代码里,有些 loader 会动态插入 style 标签,这些标签没有 nonce,又被拦了。

最后妥协:保留 'unsafe-inline' 在 style-src,但加了 hash 限制。用 Webpack 构建时算出关键 CSS 的 sha256,硬编码进 CSP:

style-src 'self' https://jztheme.com 'unsafe-inline' 'sha256-abcdefg12345...';

虽然不够完美,但至少比全开 unsafe-inline 强。这里注意我踩过好几次坑:hash 必须是 base64 编码的二进制摘要,不是 hex 字符串,搞反了等于没加。

输入过滤的土办法

XSS 还有个重灾区是富文本展示。系统里有些日志详情页,直接把后端返回的 HTML 用 v-html 渲染,典型炸弹操作。

正经做法该上 DOMPurify,但我试了下发现它和我们用的某些自定义标签不兼容,净化完直接丢内容。时间紧,最后上了个“土方案”:自己写了个简易 sanitizer,只放行基本标签和属性:

function sanitize(dirty) {
  const div = document.createElement('div');
  div.innerHTML = dirty;

  const allowedTags = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'code'];
  const allowedAttrs = ['class'];

  function walk(node) {
    for (let i = node.childNodes.length - 1; i >= 0; i--) {
      const child = node.childNodes[i];
      if (child.nodeType === 1) { // element
        if (!allowedTags.includes(child.tagName.toLowerCase())) {
          child.remove();
          continue;
        }
        // 过滤属性
        const attrs = [...child.attributes];
        attrs.forEach(attr => {
          if (!allowedAttrs.includes(attr.name)) {
            child.removeAttribute(attr.name);
          }
        });
        walk(child);
      }
    }
  }

  walk(div);
  return div.innerHTML;
}

然后在组件里这样用:

<div v-html="sanitize(logContent)"></div>

亲测能防住大部分 和 onxxx 事件注入。当然,遇到 svg 或 mathML 还是有风险,不过我们业务场景压根不用这些,暂时不管了。

接口防刷和 CSRF 的双保险

接口这块,原本只有 JWT 鉴权,但安全报告说容易被暴力试探。我们加了两层:一是限流,用 Redis 记录用户 IP + 接口路径的请求频次;二是敏感操作加二次 token 校验。

比如删除接口,前端不仅要带 JWT,还得从页面 meta 拿一个一次性 token:

<meta name="csrf-token" content="abc123xyz">

请求时塞进 header:

javascript
axios.delete('/api/item/123', {
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
}
});
>
<p>后端对比 token 是否匹配且未使用过。用 Redis 存
csrf:abc123xyz,值设为 used,TTL 5 分钟。虽然增加了复杂度,但至少防住了简单 CSRF 和自动化脚本批量提交。</p>

<h2>回顾与反思</h2>
<p>上线两周,安全扫描通过了,也没收到用户投诉。但从开发者角度看,这方案真不算优雅。CSP 的 nonce 注入是侵入式的,每次发布都得保证服务端能动态注入,要是哪天换成了纯静态部署,这套就废了。理想情况应该是构建时生成 nonce 占位符,但我们现在用的传统打包流程,改不动。</p>
<p>另外 sanitizer 那块其实还有隐患:如果后端返回的内容里有
`,虽然我在 JS 里过滤了 onerror,但如果 HTML 字符串拼接时顺序不对,可能漏掉。后来补了条规则:移除所有包含 onw+= 的属性名,算是勉强兜住。

最大的遗憾是没上 report-only 模式做灰度。本来计划先上 CSP-Report-Only 收集异常,结果测试环境没法复现生产的数据流,上报日志全是空的,最后只能生产直接上,提心吊胆半天。

总的来说,这次加固像是打了一排补丁,但确实解决了眼前问题。有些地方没做到 100% 安全,但在当前人力和周期下,已经是最优解了。至少现在半夜不会被安全告警电话吵醒了。

以上是我的项目经验,希望对你有帮助

这个技巧的拓展用法还有很多,比如 CSP 结合 SRI 做资源完整性校验,或者用 Trusted Types 做更深层防御,但对我们项目来说太重了。后续如果有新项目,我会考虑从一开始就集成这类机制,而不是等出事再补。

以上是我踩坑后的总结,有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论