每隔几个月就会刷到一条新闻:「某公司用户数据泄露」「某电商被薅羊毛」「某后台被拖库」。十有八九绕不开 Web 安全的老三样——XSS、CSRF、SQL 注入。
这三样不是新东西,OWASP Top 10 里挂了十几年了。但每年都有新人踩、老人也踩。今天这篇就把它们到底怎么打、怎么防讲清楚。每种攻击都给一个教学版的最小复现 demo(请勿用于真实环境),再给生产可用的防御方案。
一、XSS:跨站脚本攻击
核心定义:攻击者把恶意 JS 注入你的页面,在受害者浏览器里跑起来,能偷 cookie、改 DOM、调你的接口。
三种形态
| | |
|---|
| 反射型 | | https://app.com/search?q=<script>...</script> |
| 存储型 | 攻击 payload 存进数据库,所有访问者都中招 | 评论里写 <script>fetch('/api/...')</script> |
| DOM 型 | 完全前端,URL hash / innerHTML 操作 | location.hash |
存储型最危险(影响所有访问者),DOM 型最隐蔽(流量不经过后端,WAF 抓不到)。
最小复现 demo(教学版,勿用于真实环境)
<div id="content"></div>
<script>
const userInput = new URLSearchParams(location.search).get('msg');
// 反射型 XSS:访问 ?msg=<img src=x onerror=alert(1)>
document.getElementById('content').innerHTML = userInput;
</script>
注意:直接 <script>...</script> 在 innerHTML 里不会执行(浏览器规定),但 <img onerror> 这类带事件的 HTML 会触发。所以用 innerHTML 仍然非常危险。
防御核心:输出转义 + CSP 兜底
1. 输出转义按位置区分(这是新人最容易搞错的)
| |
|---|
| < > & " ' |
| |
| \u0000 |
| encodeURIComponent |
| |
前端框架默认帮你转:React 用 {userInput} 是安全的;Vue 用 {{ userInput }} 是安全的。
翻车的总是这几个:
// ❌ React 的 dangerouslySetInnerHTML(名字已经在警告你了)
<div dangerouslySetInnerHTML={{ __html: userInput }} />
// ❌ Vue 的 v-html
<div v-html="userInput"></div>
// ❌ 任何直接的 innerHTML / outerHTML / document.write
el.innerHTML = userInput;
实在要渲染富文本 → 用专门的 sanitizer:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
});
el.innerHTML = clean;
2. URL 参数防御
特别注意 href / src 别让用户输入 javascript: 开头:
function safeUrl(url) {
const trimmed = String(url).trim().toLowerCase();
if (trimmed.startsWith('javascript:') || trimmed.startsWith('data:')) {
return '#';
}
return url;
}
二、CSP:内容安全策略(XSS 兜底)
CSP 是浏览器层的 XSS 兜底——即使代码里有漏洞,浏览器也按 CSP 规则拒绝执行可疑脚本。
通过 HTTP 响应头下发:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://api.example.com; frame-ancestors 'none'; report-uri /csp-report
关键指令
| |
|---|
default-src | |
script-src | |
style-src | |
img-src | |
connect-src | XHR / fetch / WebSocket 能连哪些 |
frame-ancestors | 谁能 iframe 嵌入我(替代 X-Frame-Options) |
report-uri | |
生产可用样板
新项目推荐:用 nonce + strict-dynamic(最强 XSS 防御)
Content-Security-Policy:
default-src 'self';
script-src 'nonce-aBcDeF12345' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
object-src 'none';
base-uri 'self';
upgrade-insecure-requests;
report-uri /api/csp-report;
服务端每个请求生成随机 nonce,模板里 <script nonce="aBcDeF12345"> 才能执行:
// Express 示例
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Security-Policy',
`script-src 'nonce-${res.locals.nonce}' 'strict-dynamic'; ...`);
next();
});
<script nonce="<%= nonce %>">/* 你的内联脚本 */</script>
灰度上线:Report-Only
直接上线 CSP 容易把页面打挂(第三方脚本被拦),先用 Report-Only 模式跑一周:
Content-Security-Policy-Report-Only: ...一样的策略...
这个 header 只上报、不拦截。看违规日志发现哪些资源被漏写了,调完策略再切正式 header。
避免错误:
- ❌ CSP 里写
script-src 'unsafe-inline' 等于没写 - ❌ 抄网上 CSP 样板要看清楚版本(
strict-dynamic 是 CSP3 才有) - ❌ 默认的 CSP 主要拦"加载/执行外部脚本"那条线,对 DOM 型 XSS(如把脏数据塞进
innerHTML)防御能力有限——要彻底治 DOM XSS,得叠加 require-trusted-types-for 'script'(CSP3 引入,浏览器兼容性还在追赶),同时输出转义这步永远不能省
三、CSRF:跨站请求伪造
核心定义:你登录了 bank.com,浏览器里有 cookie;攻击者诱导你访问 evil.com,evil.com 上一个 <form action="https://bank.com/transfer" method="post"> 自动提交,浏览器自动带上 bank.com 的 cookie,钱就转走了。
CSRF 的关键在于「浏览器自动带 cookie」这个特性。
防御四件套
1. SameSite Cookie(现代浏览器自带的防线)
Set-Cookie: sessionid=abc; SameSite=Strict; Secure; HttpOnly
| |
|---|
Strict | |
Lax | |
None | 跨站正常带(必须配 Secure,否则被浏览器拒收) |
Chrome 80+ 默认就是 Lax,等于默认就帮你挡了大部分 CSRF。
但有几个场景因此挂了:
- iframe 嵌入第三方系统、跨站 SSO 回跳:可能需要
SameSite=None; Secure
2. CSRF Token(最传统也最有效)
服务端给每个会话生成一个不可预测的 token,放在表单或自定义 header 里,每次写操作都校验。
<form method="post" action="/transfer">
<input type="hidden" name="_csrf" value="aBcDeF...">
...
</form>
// 后端校验
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).send('CSRF token mismatch');
}
跨站攻击者没法读到你站点的 token(同源策略),所以伪造不出合法请求。
Express 用 csurf(已停止维护,新项目推荐 csrf-csrf 或框架自带方案);Spring Security 默认开启 CSRF Token。
3. 双重提交 Cookie(前后端分离友好)
服务端给客户端发一个 cookie(如 XSRF-TOKEN),前端读出来塞到自定义 header(如 X-XSRF-TOKEN)发请求。
服务端校验「cookie 里的值 == header 里的值」。
因为攻击者:
Axios / Angular 默认支持这个模式。
4. Origin / Referer 校验
每个跨域请求浏览器自动带 Origin header(或 Referer),后端简单校验:
const allowedOrigins = ['https://app.example.com'];
if (req.method !== 'GET' && !allowedOrigins.includes(req.headers.origin)) {
return res.status(403).send('Forbidden');
}
作为兜底防线用,不能当主防御(Origin 在某些场景可能没有)。
教学版 demo
evil.com 上的一行 HTML 就能搞事(仅教学):
<form action="https://bank.com/transfer" method="post">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>
如果 bank.com 没做任何 CSRF 防护,你登录状态下访问 evil.com 钱就飞了。
真实业务里最低标准:写操作(POST / PUT / DELETE)必须校验 CSRF Token 或 SameSite=Lax 以上。
四、SQL 注入:预编译为什么能挡
核心定义:攻击者把 SQL 语法塞进用户输入,让你的拼接 SQL 变成攻击者想要的。
教学版 demo
错误版(拼接):
// 用户输入 username = "admin' OR '1'='1"
const sql = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
// 实际执行:
// SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = '...'
// 攻击者直接登录成功
正确版(参数化查询):
// 用 ? 占位符
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.query(sql, [username, password]);
// 即使 username 是 "admin' OR '1'='1",也只是当成一个普通字符串值
预编译为什么能挡?
数据库收到带占位符的 SQL 时先编译执行计划,再把参数作为「纯数据」绑进去——SQL 结构已经定型,参数里写什么 SQL 语法都不再生效。
这是结构性防御,跟字符串里有没有引号无关,比"过滤危险字符"靠谱 10 倍。
ORM 不等于安全
很多人觉得「我用 ORM 了不用担心」。错。ORM 的 raw query / 字符串拼接的字段名都会翻车:
// ❌ Sequelize - 拼接 ORDER BY 字段名(占位符无效)
User.findAll({ order: [[req.query.sortField, 'ASC']] });
// 攻击者传 sortField = "(SELECT ...)" 就出事
// ❌ MyBatis 的 $ 拼接
SELECT * FROM users WHERE id = ${id} // 字符串拼接,危险
SELECT * FROM users WHERE id = #{id} // 预编译,安全
// ❌ MongoDB 的 $where 接受字符串
db.users.find({ $where: `this.name == '${userName}'` }) // NoSQL 注入
永远遵循:
- 字段名、表名做白名单(动态字段必须是预定义列表里的值)
- raw query 当作最后手段,能用 ORM 标准 API 别用 raw
五、文件上传攻击
经典套路:上传一个 shell.php,访问 https://app.com/uploads/shell.php 直接拿 webshell。
四道防御
1. 扩展名白名单(不是黑名单)
const allowed = ['.jpg', '.png', '.gif', '.pdf'];
const ext = path.extname(file.originalname).toLowerCase();
if (!allowed.includes(ext)) {
return res.status(400).send('Invalid file type');
}
避免:黑名单容易漏(.phtml.php5.htaccess),永远用白名单。
2. 文件头 magic number 校验
import FileType from 'file-type';
const type = await FileType.fromBuffer(fileBuffer);
if (!['image/jpeg', 'image/png', 'image/gif'].includes(type?.mime)) {
return res.status(400).send('Invalid file content');
}
防御「把 shell.php 改名 shell.jpg」的绕过。
3. 重命名 + 存储隔离
const safeName = `${crypto.randomUUID()}${ext}`; // 随机命名
const uploadPath = `/data/uploads/${safeName}`; // 存到独立目录
最关键的隔离:上传目录单独域名 + 禁止执行
# nginx 配置:上传域名禁止跑任何动态脚本
server {
server_name uploads.example.com;
root /data/uploads;
location ~ \.(php|jsp|asp|aspx|cgi)$ {
deny all; # 直接拒绝
}
}
或者干脆把文件存对象存储(OSS / S3),返回的就是文件 URL,没办法执行。
4. 大小限制 + 病毒扫描(敏感场景)
multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: ...
})
六、路径穿越与 SSRF(带过)
路径穿越:用户传文件名 ../../../etc/passwd,服务端拼接路径读取。
// ❌ 危险
res.sendFile(path.join('/data/files', req.params.filename));
// ✅ 校验最终路径仍在白名单目录内
const baseDir = path.resolve('/data/files');
const target = path.resolve(baseDir, req.params.filename);
if (!target.startsWith(baseDir + path.sep)) {
return res.status(400).send('Bad path');
}
res.sendFile(target);
SSRF:用户输入 URL,服务端代为请求。攻击者传 http://169.254.169.254/latest/meta-data/(云厂商元数据服务)就能偷你的 IAM 凭证。
// 防御:白名单域名 + 拦截内网 IP / 元数据地址
const blocked = ['127.0.0.1', 'localhost', '169.254.169.254', '10.', '192.168.', '172.16.'];
云上服务一定要把元数据访问加强(AWS 用 IMDSv2、阿里云配 ram trust 限制)。
七、安全测试工具
| |
|---|
| sqlmap | SQL 注入自动化扫描,能从一个有漏洞的 URL 直接 dump 全库 |
| OWASP ZAP | |
| Burp Suite | |
| Semgrep / Bandit | 源码层 SAST,可集成 CI 静态扫描安全反模式 |
| DependencyTrack / Snyk | |
CI 里建议至少跑 Semgrep + Snyk,能拦住 80% 的低级漏洞。
八、三大攻击对比表
九、写在最后
Web 安全没有银弹,但有几条铁律记住基本不会出大事:
- 永远不信任用户输入
- 服务端是最后一道防线
- 白名单优于黑名单
- 结构性防御优于过滤
- 纵深防御(CSP 是 XSS 兜底、SameSite 是 CSRF 兜底,多层叠加)
最后说一句:别用 ORM 就以为自己安全,别用 React 就以为没 XSS。框架默认安全,但 dangerouslySetInnerHTML 和 raw query 这种逃生舱用错了就裸奔。
阅读原文:点击这里
该文章在 2026/7/18 23:22:37 编辑过