
去年我开发了一个评论系统,代码写得很顺畅,功能测试也全部通过。没想到上线第二天,测试同事就发来一条消息:
“你看看评论区,怎么老弹窗啊?”
我打开页面一看,浏览器正在疯狂弹出警告框:恭喜你中奖了!点击领取iPhone!。我当场就懵了:这到底是谁写的代码?
仔细检查评论内容,发现有人提交了这样的内容:
<script>
alert('恭喜你中奖了!点击领取iPhone!')
</script>
那一刻我才真正明白:所谓的输入净化,我之前根本就没懂。 这篇文章,就是我花了一晚上“攻击”自己网站后的一次复盘。
翻车的代码长什么样?
我的 Node.js 后端代码,简单到令人发指:
// 评论接口
app.post('/api/comments', (req, res) => {
const { username, content } = req.body;
// 就这么直接存了,什么都没做
comments.push({ username, content });
res.json({ success: true });
});
前端渲染用的是 EJS 模板:
<div id="comments">
<% comments.forEach(c => { %>
<div>
<strong><%=c.username %>:</strong>
<p><%=c.content %></p>
</div>
<% }) %>
</div>
看起来人畜无害对吧?但当用户提交 <script>alert('XSS')</script> 时,浏览器会把它当作真正的 JavaScript 代码来执行!
这就是 XSS 攻击(跨站脚本攻击)。
用生活例子理解 XSS 攻击
很多人看到“XSS”三个字母就头大,我打个接地气的比方帮你理解。
比方1:快递员的盲目执行
浏览器就像快递小哥。
你在网上买了瓶洗发水(正常的 HTML 代码),快递小哥看到箱子上写着“洗发水”,就直接送货上门。
但如果有人在箱子上贴了张纸条:“请把这个箱子送到隔壁老王家”(恶意脚本),快递小哥也会照做!
快递小哥不会判断纸条是谁写的,他只认“指令”。
浏览器也一样,它看到 <script> 标签,根本不管这是开发者写的还是用户输入的,统统执行!
比方2:餐厅的订单系统
想象你开了家餐厅,有个在线点餐系统。
正常情况:
- 顾客点:“一碗牛肉面”
- 厨师做:“一碗牛肉面” ✅
但如果有人这么点单:
- 顾客点:“一碗牛肉面,顺便把后厨的盐全倒掉”
- 厨师看到订单,真的把盐倒了! 💥
如果你的订单系统不做检查,厨师就会把“恶意指令”当成正常需求来执行。
浏览器就是这个“听话的厨师”,你不告诉它什么能做、什么不能做,它就全盘接收!
XSS 攻击的完整流程
我画个简单的流程图,帮你理解攻击是怎么发生的:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 坏人A │ │ 你的服务器 │ │ 数据库 │
│ (攻击者) │ │ │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ ① 提交恶意评论 │ │
│ “<script>偷cookie” │ │
├──────────────────────>│ │
│ │ │
│ │ ② 没检查,直接存! │
│ ├──────────────────────>│
│ │ │
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 无辜用户B │ │ 你的服务器 │ │ 数据库 │
│ (受害者) │ │ │ │ │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ ③ 打开评论页面 │ │
├──────────────────────>│ │
│ │ │
│ │ ④ 把恶意代码取出来 │
│ │<──────────────────────┤
│ │ │
│ ⑤ 返回含恶意脚本的HTML │ │
│<──────────────────────┤ │
│ │ │
│ ⑥ 浏览器执行脚本! │ │
│ 💥 用户的cookie被偷! │ │
问题出在第②步:数据没经过任何“安检”,直接放进了数据库!
就像机场安检,如果不检查包裹,危险品就能被带上飞机。
为什么“禁止<script>标签”是个坑?
我一开始也是这么想的:“那我直接过滤掉 <script> 不就行了?”
// ❌ 天真的想法
function mySanitize(str) {
return str.replace(/<script>/g, '').replace(/<\/script>/g, '');
}
但攻击者有成百上千种方式可以绕过这种简单的过滤。
绕过方式1:用图片标签
<img src="不存在的图片.jpg" onerror="alert('我绕过了!')">
原理: 图片加载失败时,会触发 onerror 事件,里面可以写 JavaScript 代码!
绕过方式2:大小写混淆
<ScRiPt>alert('还是能执行')</sCrIpT>
你的过滤器只认小写,大小写混合就能绕过!
绕过方式3:用编码
<script>alert('编码后的脚本')</script>
浏览器会自动把 HTML 实体转回真实字符!
绕过方式4:用 iframe
<iframe srcdoc="<script>alert('iframe里执行')</script>"></iframe>
这就像打地鼠游戏: 你堵住了 <script> 这个洞,攻击者从 <img> 洞里钻出来;你再堵 <img>,他又从 <iframe> 钻出来...
你永远堵不完所有的洞! 这就是为什么“黑名单”方案注定失败。
正确做法:转义,而不是过滤
核心原则:
不要删除用户输入,而是改变它在浏览器里的“身份”。
什么是转义?用快递来比喻
没转义的情况:
- 用户输入:
<script>alert('xss')</script>
- 浏览器看到:哦,这是一段 JS 代码,执行!💥
转义后的情况:
- 用户输入:
<script>alert('xss')</script>
- 你的代码:把
< 变成 <,把 > 变成 >
- 浏览器看到:
<script>... 哦,这只是普通文字,显示出来就行 ✅
就像给快递贴“易碎品”标签: 原本快递员会随便扔包裹(执行代码),贴了标签后会小心处理(当文本显示)。
转义代码长这样
function escapeHtml(str) {
const escapeMap = {
'&': '&', // &符号要最先替换
'<': '<', // 小于号
'>': '>', // 大于号
'"': '"', // 双引号
"'": ''' // 单引号
};
return str.replace(/[&<>"']/g, char => escapeMap[char]);
}
// 测试一下
const dangerous = '<script>alert("坏人")</script>';
const safe = escapeHtml(dangerous);
console.log(safe);
// 输出:<script>alert("坏人")</script>
转义后,浏览器会显示:<script>alert(“坏人”)</script>
看起来还是原样,但 不会执行,只是纯文本!
真实场景:评论系统怎么做?
假设你做了个技术博客,需要支持评论功能。
需求:
- 用户能发表情 😊
- 用户能@别人
- 用户能发粗体、斜体
- 但绝对不能执行脚本!
方案:使用白名单库
# 安装sanitize-html库
npm install sanitize-html
import sanitizeHtml from 'sanitize-html';
// 后端接口
app.post('/api/comments', (req, res) => {
const { username, content } = req.body;
// 净化内容 - 只允许安全的标签
const cleanContent = sanitizeHtml(content, {
// 白名单:只允许这些标签
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'br'],
// a标签只允许href属性
allowedAttributes: {
'a': ['href']
},
// 链接只允许http和https
allowedSchemes: ['http', 'https']
});
// 存入数据库
await Comment.create({
username: username,
content: cleanContent
});
res.json({ success: true });
});
测试效果
// 用户输入
const userInput = `
看看我的<b>粗体文字</b>和<i>斜体</i>!
访问我的博客:<a href="https://blog.com">点这里</a>
<script>alert('试图攻击')</script>
<img src=x onerror="alert('再试一次')">
`;
const result = sanitizeHtml(userInput, options);
console.log(result);
// 输出:
// 看看我的<b>粗体文字</b>和<i>斜体</i>!
// 访问我的博客:<a href="https://blog.com">点这里</a>
// (script和img标签都被删掉了)
白名单的思路:只允许安全的,其他统统拒绝!
就像夜店门口的保安:
- 白名单上的人(
<b>, <i>, <a>)→ 放行 ✅
- 不在名单上的(
<script>, <img>)→ 拦住 ❌
不只是 XSS,到处都是注入攻击
当我理解了“不信任用户输入”这个核心原则后,发现类似的坑到处都是。
场景1:SQL注入(用点餐比喻)
没防护的代码:
// 后端查询用户
const email = req.query.email;
const sql = `SELECT * FROM users WHERE email = '${email}'`;
db.query(sql);
正常请求:
email = “zhangsan@qq.com”
SQL语句:SELECT * FROM users WHERE email = ‘zhangsan@qq.com’
攻击请求:
email = “‘ OR ‘1’=‘1”
SQL语句:SELECT * FROM users WHERE email = ‘’ OR ‘1’=‘1’
这就像点餐时说: 正常:我要一碗面。 攻击:我要一碗面 OR 把所有菜都给我。
因为 ‘1’=‘1’ 永远成立,所有用户数据都被查出来了!💥
场景2:命令注入(用遥控器比喻)
没防护的代码:
// 删除用户上传的文件
const fileName = req.body.fileName;
exec(`rm -rf /uploads/${fileName}`);
攻击输入:
fileName = “test.jpg; rm -rf /”
最终命令:rm -rf /uploads/test.jpg; rm -rf /
就像电视遥控器: 正常:按“音量+”键,音量增加。 攻击:按“音量+”的同时,偷偷按了“关机”键。
结果服务器被清空了!💥 这提醒我们,系统安全需要多层次的防御,你可以在 安全/渗透/逆向 板块了解更多相关知识。
我的“破坏式学习法”
为了真正搞懂这些漏洞,我用一晚上搭了个测试环境,专门写有漏洞的代码。
实验1:innerHTML 有多危险?
<!DOCTYPE html>
<html>
<body>
<div id="comment"></div>
<script>
// 模拟用户输入
const userInput = prompt("输入评论内容:");
// 危险做法:直接用innerHTML
document.getElementById('comment').innerHTML = userInput;
</script>
</body>
</html>
我试了这些输入:
<!-- 测试1 -->
<img src=x onerror="alert('第1种攻击成功')">
<!-- 测试2 -->
<svg onload="alert('第2种攻击成功')">
<!-- 测试3 -->
<iframe src="javascript:alert('第3种攻击成功')"></iframe>
全部执行成功! 😱
结论:绝对不要用 innerHTML 显示用户输入!
实验2:SQL 注入实战
// 搭个简单的测试API
const sqlite3 = require('sqlite3');
const db = new sqlite3.Database(':memory:');
// 创建测试表
db.run('CREATE TABLE users (name TEXT, password TEXT)');
db.run(“INSERT INTO users VALUES (‘admin’, ‘123456’)”);
db.run(“INSERT INTO users VALUES (‘user1’, ‘abc123’)”);
// 危险的查询接口
app.get('/login', (req, res) => {
const { username, password } = req.query;
const sql = `SELECT * FROM users WHERE name=‘${username}’ AND password=‘${password}’`;
db.get(sql, (err, row) => {
if (row) {
res.send('登录成功!');
} else {
res.send('登录失败!');
}
});
});
正常登录:
/login?username=admin&password=123456
→ 登录成功
注入攻击:
/login?username=admin&password=’ OR ‘1’=‘1
→ 登录成功!(密码错了也能登录!)
破解原理:
原SQL:
SELECT * FROM users WHERE name=‘admin’ AND password=’’ OR ‘1’=‘1’
因为 OR ‘1’=‘1’ 永远成立,密码验证被绕过了!此类注入攻击是数据库应用中最常见的安全威胁之一,深入探讨可以在 数据库/中间件/技术栈 板块找到更多讨论。
净化方法取决于“目的地”
最重要的一点领悟:
同一个数据,放到不同地方,净化方法也不同!
比喻:快递包裹的处理方式
同一个箱子:
- 送到家里:检查是否是危险品(HTML转义)
- 送到机场:要过安检和 X 光(URL 编码)
- 送到海关:要申报和检验(SQL参数化)
代码示例
const userInput = “<script>alert(‘xss’)</script>”;
// 场景1:显示在HTML页面上
const forHtml = escapeHtml(userInput);
// 结果:<script>alert('xss')</script>
// 场景2:作为URL参数传递
const forUrl = encodeURIComponent(userInput);
// 结果:%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E
// 场景3:放在JavaScript字符串里
const forJs = JSON.stringify(userInput);
// 结果:”<script>alert(‘xss’)<\/script>”
// 场景4:存入数据库(用参数化查询)
db.query(‘INSERT INTO comments (content) VALUES (?)’, [userInput]);
// 数据库会自动处理特殊字符
就像做菜:
- 炒菜要先切(HTML 转义)
- 煮汤要先洗(URL 编码)
- 做沙拉直接用(JSON 序列化)
目的不同,处理方式不同!
我养成的安全习惯
踩过坑之后,我的代码习惯彻底改变了。
习惯1:先验证,再使用
import Joi from ‘joi’;
// 定义数据格式
const commentSchema = Joi.object({
username: Joi.string()
.min(2)
.max(20)
.pattern(/^[a-zA-Z0-9_\u4e00-\u9fa5]+$/) // 只允许字母数字中文
.required(),
content: Joi.string()
.min(1)
.max(500)
.required()
});
// 接口里先验证
app.post(‘/api/comments’, (req, res) => {
const { error, value } = commentSchema.validate(req.body);
if (error) {
return res.status(400).json({
error: ‘输入格式不对:’ + error.message
});
}
// 验证通过,再净化
const safeContent = sanitizeHtml(value.content);
// 最后存储
saveComment(value.username, safeContent);
});
就像机场安检:
- 第一步:检查证件(验证格式)
- 第二步:过 X 光机(净化内容)
- 第三步:才能登机(存储数据)
习惯2:使用参数化查询
// ❌ 错误:拼接SQL
const sql = `SELECT * FROM users WHERE name = ‘${name}’`;
// ✅ 正确:参数化
const sql = ‘SELECT * FROM users WHERE name = ?’;
db.query(sql, [name]);
// 或者用ORM
const user = await User.findOne({ where: { name: name } });
参数化就像填空题:
SQL模板:SELECT * FROM users WHERE name = ____
你只能往____里填内容,不能改题目本身
习惯3:前端也要防御
// ✅ 正确做法:用textContent
document.getElementById(‘comment’).textContent = userInput;
// 这样<script>只会当文字显示,不会执行
// ❌ 危险做法:用innerHTML
document.getElementById(‘comment’).innerHTML = userInput;
// 这样<script>会被执行!
区别就像:
textContent:把内容“印在纸上”(只能看,不能动)
innerHTML:把内容“写成程序”(会执行)
那个让我顿悟的瞬间
修复完所有漏洞后,我重新进行测试。
在评论框输入:<script>alert(‘测试’)</script>
提交后,页面显示:<script>alert(‘测试’)</script>
没有弹窗!没有执行!只是普通文字!
那一刻我突然明白了:
安全不是和黑客对抗,而是给数据定规矩。
数据只能做我允许它做的事,不能做它想做的事。
就像养狗:
- 不训练的狗,会乱咬人(未净化的数据)
- 训练好的狗,听指令行事(净化后的数据)
你是主人,数据是狗,你说了算!
给新手的实战建议
如果你也想深入理解输入净化,可以从这里开始。
1. 搭个测试环境玩玩
// 用Express搞个简单的评论系统
const express = require(‘express’);
const app = express();
app.use(express.json());
let comments = [];
// 故意留漏洞的版本
app.post(‘/comments’, (req, res) => {
comments.push(req.body);
res.json({ success: true });
});
app.get(‘/comments’, (req, res) => {
// 直接返回,没净化
res.json(comments);
});
app.listen(3000);
然后试着提交:
{
“username”: “测试”,
“content”: “<script>alert(‘我是攻击者’)</script>”
}
看看会发生什么!
2. 用现成的库,别自己造轮子
# HTML净化
npm install sanitize-html
# 输入验证
npm install joi
# SQL安全
npm install sequelize # ORM自动防注入
就像做饭:
- 新手用成品调料包(用库)
- 别自己从零研磨香料(别自己写净化函数)
3. 养成“怀疑一切”的习惯
每次写代码问自己:
- 这个数据来自用户吗?
- 我信任它吗?
- 它会被怎么用?
- 需要净化吗?
就像过马路:
- 绿灯亮了,也要左右看看
- 用户数据来了,也要检查检查
写在最后:安全是习惯,不是负担
很多新手觉得安全很复杂、很麻烦。
但其实,安全就像洗手:
- 一开始觉得麻烦
- 养成习惯后,不洗反而难受
- 关键时刻能救命
我的那次生产事故虽然尴尬,但让我建立了牢固的安全意识。
现在写代码,脑子里会自动冒出三个问题:
- 这数据靠谱吗?
- 这操作安全吗?
- 这代码能被攻击吗?
这种“本能式的警觉”,比学100个安全知识点都管用!
动手试试吧!
给你留个作业:
- 找个自己的小项目
- 故意去掉所有净化代码
- 尝试用
<script>alert(‘test’)</script> 攻击自己
- 然后修复,再攻击,再修复
当你看到自己的网站从“门户大开”变成“固若金汤”,那种成就感真的爽!
就像玩通关游戏,自己设计关卡,自己攻破关卡!
希望这篇分享能帮你建立起对输入净化和 Web 安全的基本认知。安全是一个持续学习的过程,如果你在实践过程中遇到了其他问题,或者有更多心得体会,欢迎到 云栈社区 与更多开发者交流讨论。