输入过滤实战:防御XSS与SQL注入的关键技术
项目初期的技术选型
去年接了个后台管理系统,用户能提交富文本、表单、甚至上传 CSV 导入数据。一开始我心想,不就是个 CRUD 吗?前端校验一下格式,后端再存数据库,稳得很。但上线前安全团队扫了一波,直接报了 XSS 和 SQL 注入风险,尤其是富文本区域——用户粘贴一段带 script 的 HTML,页面就直接执行了。这下傻眼了。
于是赶紧补输入过滤。其实说白了,核心思路就两点:前端做基础体验优化(比如不让用户输奇怪字符),但绝不信任前端;所有关键过滤必须放在后端。我们用的是 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();
});
这个方案简单粗暴,但覆盖了大部分场景。像换行符、制表符这些正常字符保留,只干掉可能引发解析异常的控制字符。实测没影响正常业务,但堵住了几个潜在漏洞。
最终的解决方案
综合下来,我们的输入过滤策略分三层:
- 前端:用
input的pattern或 React 的实时校验做体验优化(比如邮箱格式),但绝不依赖它保安全。 - 后端通用层:Express 中间件自动 strip 控制字符,对富文本字段用 DOMPurify 白名单净化。
- 后端业务层:敏感操作(如执行命令)额外做关键词黑名单(比如过滤
..、/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% 的风险。记住一点:永远别信用户输入,哪怕他看起来人畜无害。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理富文本性能问题的?

暂无评论