输入过滤实战:防御XSS与SQL注入的关键技术

晓娜的笔记 安全 阅读 1,458
赞 64 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年接了个后台管理系统,用户能提交富文本、表单、甚至上传 CSV 导入数据。一开始我心想,不就是个 CRUD 吗?前端校验一下格式,后端再存数据库,稳得很。但上线前安全团队扫了一波,直接报了 XSS 和 SQL 注入风险,尤其是富文本区域——用户粘贴一段带 script 的 HTML,页面就直接执行了。这下傻眼了。

输入过滤实战:防御XSS与SQL注入的关键技术

于是赶紧补输入过滤。其实说白了,核心思路就两点:前端做基础体验优化(比如不让用户输奇怪字符),但绝不信任前端;所有关键过滤必须放在后端。我们用的是 Node.js + Express,所以重点在后端处理。

最开始的“天真”方案

我第一反应是:用正则干掉所有 <script> 标签不就完了?写了个简单函数:

function naiveSanitize(str) {
  return str.replace(/<scriptb[^<]*(?:(?!</script>)<[^<]*)*</script>/gi, '');
}

结果测试一跑,用户输入 <img src=x onerror=alert(1)>,照样弹窗。淦,忘了还有事件属性。又加了一堆正则匹配 onw+javascript:……但越写越乱,漏网之鱼一堆,比如 <svg onload=...> 这种冷门标签根本覆盖不到。折腾半天,发现靠正则手写过滤器纯属自虐——HTML 解析太复杂,正则根本不适合干这事。

转向专业库:DOMPurify

后来老同事提醒:别重复造轮子,用 DOMPurify。这库专门干 HTML 净化,基于浏览器的 DOM 解析,能准确识别并移除危险节点和属性。立马集成:

const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

// 在 Express 中间件里用
app.use('/api/submit', (req, res, next) => {
  if (req.body.content) {
    req.body.content = DOMPurify.sanitize(req.body.content, {
      ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
      ALLOWED_ATTR: ['href', 'target']
    });
  }
  next();
});

效果立竿见影。之前那些花里胡哨的 XSS payload 全被干掉了,只保留白名单内的标签和属性。而且配置灵活,比如我们允许超链接,但禁止 onclick,一行配置搞定。亲测有效,省了我至少三天 debug 时间。

最大的坑:性能问题

本以为万事大吉,结果压测时翻车了。导入 1000 条含富文本的记录,接口响应时间从 200ms 飙到 5s+。查日志发现,DOMPurify 在大量文本处理时开销不小——毕竟它要构建虚拟 DOM 树再遍历清理。用户批量导入功能直接卡死。

折腾了半天,发现两个优化点:

  • 避免重复净化:有些字段根本不需要 HTML(比如用户名、标题),但中间件无脑全字段过一遍。赶紧加个字段白名单,只处理明确需要富文本的字段。
  • 缓存净化结果:对于重复内容(比如导入模板里的固定说明),用 LRU 缓存。但要注意内存别爆,设了 1000 条上限。

改完后,批量导入回到 800ms 内,虽然还是比纯文本慢,但业务可接受。这里注意我踩过好几次坑:缓存 key 一定要用原始字符串(比如加盐哈希),否则不同用户输入相同内容会串数据。

非 HTML 输入的过滤

除了富文本,普通表单字段也得防。比如搜索框,用户输个 ' OR '1'='1,万一后端拼 SQL 就凉了。不过我们用的是 ORM(Sequelize),参数化查询天然防注入,所以这块压力不大。但为了保险,还是对所有字符串做了基础过滤:

// 移除控制字符(0x00-0x1F),防止协议混淆等
function stripControlChars(str) {
  return str.replace(/[x00-x1fx7f]/g, '');
}

// 在中间件里加
app.use((req, res, next) => {
  if (typeof req.body === 'object' && req.body !== null) {
    Object.keys(req.body).forEach(key => {
      if (typeof req.body[key] === 'string') {
        req.body[key] = stripControlChars(req.body[key]);
      }
    });
  }
  next();
});

这个方案简单粗暴,但覆盖了大部分场景。像换行符、制表符这些正常字符保留,只干掉可能引发解析异常的控制字符。实测没影响正常业务,但堵住了几个潜在漏洞。

最终的解决方案

综合下来,我们的输入过滤策略分三层:

  1. 前端:用 inputpattern 或 React 的实时校验做体验优化(比如邮箱格式),但绝不依赖它保安全。
  2. 后端通用层:Express 中间件自动 strip 控制字符,对富文本字段用 DOMPurify 白名单净化。
  3. 后端业务层:敏感操作(如执行命令)额外做关键词黑名单(比如过滤 ../etc),但仅限必要场景,避免过度设计。

代码结构上,把净化逻辑抽成独立模块 sanitizer.js,方便复用和测试:

// sanitizer.js
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

const STRIP_CONTROL_CHARS = /[x00-x1fx7f]/g;

exports.sanitizeRichText = (html) => {
  if (!html) return '';
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href', 'target']
  });
};

exports.stripControlChars = (str) => {
  if (typeof str !== 'string') return str;
  return str.replace(STRIP_CONTROL_CHARS, '');
};

回顾与反思

这套方案上线半年,没再出过安全工单,算是稳了。做得好的地方是:用专业库解决核心问题(DOMPurify),避免手写正则的坑;分层处理,不搞一刀切。但也有不足:

  • DOMPurify 在 Node 环境依赖 JSDOM,启动有点慢,不过我们服务常驻,影响不大。
  • 有个小问题一直没动:用户粘贴 Word 生成的 HTML,净化后样式全丢,体验不好。但业务方说“能看就行”,就没加 CSS 白名单——毕竟安全优先。
  • 理论上,最彻底的做法是存储原始输入 + 单独存储净化后版本,但项目时间紧,我们直接覆盖了原始值。如果未来要支持“原始内容审计”,得重构。

总的来说,输入过滤不是银弹,但结合白名单、专业库和分层防御,能 cover 99% 的风险。记住一点:永远别信用户输入,哪怕他看起来人畜无害。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理富文本性能问题的?

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

暂无评论