Web 安全实战
Web 安全实战手册:梳理 OWASP Top 10 威胁,覆盖 SQL 注入、XSS、CSRF、越权、文件上传、密钥管理等场景,每种攻击给出原理、示例与防御方案,并附上线前检查清单。
一、威胁模型与安全基线
1.1 先明确攻击面
一个典型 Web 应用的攻击面:
用户输入(表单/URL/Header/Cookie/上传文件) ↓前端渲染(XSS) ↓接口层(越权、参数篡改、重放) ↓业务逻辑(竞态、并发、金额篡改) ↓数据访问(SQL 注入、越权查询) ↓系统层(命令注入、文件读取、SSRF) ↓依赖与配置(过期组件、密钥泄露)核心认知:所有来自客户端的输入都是不可信的——包括被前端校验过的、被 JS 限制过的、藏在 Header 里的。
1.2 三条基本原则
| 原则 | 含义 | 实践 |
|---|---|---|
| 最小权限 | 只给必需的权限 | 数据库账号不给 DROP;文件服务只读指定目录;接口只返回必要字段 |
| 纵深防御 | 不依赖单一防线 | 前端校验 + 后端校验 + 数据库约束 + WAF |
| 默认安全 | 默认拒绝,显式放行 | 路由默认需鉴权;上传默认拒绝;CORS 默认不放开 |
1.3 OWASP Top 10 概览
| # | 风险 | 本文对应章节 |
|---|---|---|
| A01 | 权限控制失效 | 3.4 越权 |
| A02 | 加密机制失效 | 3.1 密码存储、8 传输安全 |
| A03 | 注入(SQL/XSS/命令) | 2 注入类漏洞 |
| A04 | 不安全设计 | 6 业务逻辑安全 |
| A05 | 安全配置错误 | 7 配置与密钥 |
| A06 | 易受攻击的组件 | 9 依赖安全 |
| A07 | 身份认证失效 | 3 认证与授权 |
| A08 | 软件和数据完整性失效 | 5 文件上传 |
| A09 | 日志与监控不足 | 10 日志与应急 |
| A10 | SSRF | 4.4 SSRF |
二、注入类漏洞
2.1 SQL 注入
原理:用户输入被当作 SQL 语句的一部分执行。
// ❌ 危险:字符串拼接$sql = "SELECT * FROM users WHERE username = '" . $_GET['name'] . "'";// 输入 ' OR '1'='1 → SELECT * FROM users WHERE username = '' OR '1'='1'// 输入 '; DROP TABLE users; -- → 直接删表防御:参数化查询(唯一正解)
// ✅ PDO 预处理$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ?');$stmt->execute([$name]);// ✅ JDBC PreparedStatementString sql = "SELECT * FROM users WHERE username = ?";try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, name); ResultSet rs = ps.executeQuery();}// ✅ mysql2 占位符const [rows] = await pool.query('SELECT * FROM users WHERE username = ?', [name]);// ✅ ORM(TypeORM / Prisma / Sequelize)自动参数化await userRepo.findOne({ where: { username: name } });常见误区:
| 误区 | 说明 |
|---|---|
用 addslashes() 转义 |
无法覆盖所有字符集与场景,仍可绕过 |
| 用正则过滤关键字 | 变形绕过(大小写、注释、编码)防不住 |
只对数字字段做 intval() |
字符串字段依然危险 |
| ORM 就绝对安全 | 使用原生 SQL 拼接(query() 字符串拼接)时依然会被注入 |
ORDER BY / LIMIT 场景:占位符不能用于标识符,必须用白名单:
const ALLOWED_SORT = ['created_at', 'price', 'sales']const sort = ALLOWED_SORT.includes(req.query.sort) ? req.query.sort : 'created_at'const order = req.query.order === 'asc' ? 'ASC' : 'DESC'const sql = `SELECT * FROM products ORDER BY ${sort} ${order} LIMIT ?`2.2 命令注入
原理:用户输入被拼进 shell 命令。
// ❌ 危险:shell 拼接const { exec } = require('child_process')exec(`convert ${userFile} -resize 800x800 out.png`)// 输入 "a.jpg; rm -rf /" → 灾难防御:避免 shell,用参数数组
// ✅ execFile / spawn:不经过 shellconst { execFile } = require('child_process')execFile('convert', [userFile, '-resize', '800x800', 'out.png'])// ✅ 每个参数单独转义$cmd = 'convert ' . escapeshellarg($file) . ' -resize 800x800 out.png';根本建议:能用库就不调命令行。图片处理用 sharp / Pillow / GD,不必调用 convert。
2.3 XSS(跨站脚本)
三种类型:
| 类型 | 触发方式 | 典型场景 |
|---|---|---|
| 存储型 | 恶意脚本存进数据库,其他用户浏览时执行 | 评论、昵称、个人简介 |
| 反射型 | 脚本在 URL 中,服务端原样回显 | 搜索关键词回显、错误页 |
| DOM 型 | 前端 JS 把不可信数据写进 DOM | innerHTML、document.write |
防御一:输出转义(按上下文)
// ✅ HTML 文本上下文function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''')}不同上下文需要不同转义:HTML 文本、HTML 属性、JS 字符串、URL 参数、CSS——混用会失效。
<!-- ❌ 属性上下文用 HTML 转义不够 --><a href="javascript:alert(1)">链接</a><!-- ✅ URL 参数要先做协议白名单 + URL 编码 -->防御二:避免危险 API
// ❌ 危险el.innerHTML = userContentdocument.write(userContent)eval(userContent)// ✅ 安全el.textContent = userContentel.setAttribute('data-x', userContent)防御三:富文本用白名单净化
// 引入 DOMPurify,只允许安全标签import DOMPurify from 'dompurify'el.innerHTML = DOMPurify.sanitize(html, { ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'code', 'pre', 'img'], ALLOWED_ATTR: ['href', 'src', 'alt'],})防御四:CSP(内容安全策略)
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; object-src 'none'; base-uri 'self';";CSP 是最后一道防线:即使有脚本注入,也无法外联或执行内联脚本。
防御五:Cookie 加固
Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax; Path=/HttpOnly 让 JS 读不到 Cookie,即使 XSS 成功也拿不到会话。
三、认证与授权
3.1 密码存储
绝对不要:明文、MD5、SHA1、单次 SHA256(都可被彩虹表或 GPU 暴力破解)。
// ✅ bcryptconst bcrypt = require('bcrypt')const hash = await bcrypt.hash(password, 12) // 存储const ok = await bcrypt.compare(inputPwd, hash) // 校验// ✅ PHP 内置$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);if (password_verify($inputPwd, $hash)) { /* 通过 */ }// ✅ Spring Security BCryptPasswordEncoderString hash = encoder.encode(password);boolean ok = encoder.matches(inputPwd, hash);| 算法 | 建议 | 说明 |
|---|---|---|
| bcrypt | ✅ 默认选择 | cost 10–12;单次计算约 100ms |
| argon2id | ✅ 更优 | 抗 GPU/ASIC,内存硬 |
| scrypt | ✅ 可选 | 内存硬 |
| MD5 / SHA1 | ❌ 禁止 | 已被彻底破解 |
补充措施:登录失败次数限制、异常登录提醒、强制弱密码检测。
3.2 JWT 安全实践
JWT 结构:Header.Payload.Signature,Payload 是 Base64 编码,不是加密——任何人都能解开看内容。
// ❌ 常见错误:把敏感信息塞进 payload{ "userId": 1, "phone": "13800000000", "idCard": "..." } // 全部明文可见// ✅ 只放必要标识{ "sub": "user_1024", "role": "member", "exp": 1735689600 }四个必须注意的点:
| 问题 | 正确做法 |
|---|---|
算法混淆攻击(alg: none) |
服务端显式指定算法,不信任 header 里的 alg |
| 密钥太弱 | 用 32 字节以上随机密钥,存环境变量 |
| 无法主动失效 | 短期 access token(15–30 min)+ refresh token,或维护黑名单 |
| 敏感操作无二次校验 | 改密码、支付等操作要二次验证,不能只凭 token |
// ✅ 签发与校验(显式指定算法)const token = jwt.sign({ sub: user.id, role: user.role }, SECRET, { algorithm: 'HS256', expiresIn: '30m',})const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] })3.3 Session 安全
// ✅ 关键配置app.use(session({ key: 'sid', secret: process.env.SESSION_SECRET, // 不硬编码 httpOnly: true, secure: true, // 仅 HTTPS sameSite: 'lax', maxAge: 7 * 24 * 3600 * 1000,}))登录后必须重新生成 Session ID(防会话固定攻击):
req.session.regenerate((err) => { req.session.userId = user.id // 在再生成之后写入})3.4 越权(最高频的线上漏洞)
两类越权:
| 类型 | 含义 | 示例 |
|---|---|---|
| 水平越权 | 同角色间越界访问他人数据 | 改 orderId=1001 为 1002 看到别人的订单 |
| 垂直越权 | 低权限用户执行高权限操作 | 普通用户直接调 /api/admin/users |
根因:只校验了「是否登录」,没校验「这个资源是不是你的」。
// ❌ 只验登录,不验归属router.get('/orders/:id', auth, async (ctx) => { ctx.body = await orderRepo.findById(ctx.params.id) // 谁都能查任何订单})// ✅ 查询时带上归属条件router.get('/orders/:id', auth, async (ctx) => { const order = await orderRepo.findOne({ where: { id: ctx.params.id, userId: ctx.state.user.id }, // 关键 }) if (!order) ctx.throw(404, '订单不存在') // 不暴露「存在但无权」的信息 ctx.body = order})防御要点:
- 查询层做归属过滤,而不是查出来再判断——避免遗漏。
- 返回 404 而非 403:不泄露资源是否存在。
- 垂直越权用统一中间件:按角色声明路由所需权限,不靠每个接口手写。
- ID 用不可枚举的标识(如 UUID/雪花 ID),降低遍历风险——但这不是防御手段,只是降低危害。
四、其他高频风险
4.1 CSRF(跨站请求伪造)
原理:用户已登录站点 A,访问恶意站点 B,B 自动向 A 发起请求,浏览器自动带上 A 的 Cookie。
防御三件套:
// 1. SameSite Cookie(首选,现代浏览器默认)res.cookie('sid', value, { sameSite: 'lax', httpOnly: true, secure: true })// 2. CSRF Token(对状态变更操作)// 服务端下发 token → 前端放请求头 → 服务端比对// 3. 校验 Origin / Refererconst origin = ctx.get('Origin')if (origin && !ALLOWED_ORIGINS.includes(origin)) ctx.throw(403)注意:GET 请求不应产生副作用(查询、删除都应走 POST/DELETE),这是 CSRF 防御的前提。
4.2 CORS 配置错误
// ❌ 危险:反射任意 Origin + 允许携带凭证app.use(cors({ origin: (o, cb) => cb(null, true), credentials: true }))// 等于任何网站都能带着用户 Cookie 读你的接口// ✅ 白名单const ALLOWED = ['https://app.example.com', 'https://admin.example.com']app.use(cors({ origin: (o, cb) => cb(null, ALLOWED.includes(o)), credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE'],}))4.3 文件上传
四层校验,缺一不可:
// 1. 扩展名白名单(不是黑名单)const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.webp']// 2. MIME 类型// 3. 文件头魔数(最关键,扩展名可伪造)const MAGIC = [ { sig: Buffer.from([0x89, 0x50, 0x4e, 0x47]), ext: '.png' }, { sig: Buffer.from([0xff, 0xd8, 0xff]), ext: '.jpg' },]// 4. 大小限制 + 重命名存储(不用用户原始文件名)const filename = `${Date.now()}_${crypto.randomUUID()}${ext}`其他必须做的:
| 措施 | 原因 |
|---|---|
| 存储目录禁止执行脚本 | 防止上传 .php/.jsp 后被直接访问执行 |
| 上传目录与站点根目录分离 | 或由对象存储托管 |
| 图片二次处理(重新编码) | 剥离内嵌的恶意载荷(图片马) |
| 文件名路径穿越校验 | 拒绝 ../、绝对路径 |
# Nginx:上传目录禁止执行 PHPlocation ^~ /uploads/ { location ~ \.(php|php5|phtml)$ { deny all; }}4.4 SSRF(服务端请求伪造)
原理:服务端根据用户输入去请求 URL,攻击者诱导它访问内网。
// ❌ 危险:用户可控 URLconst data = await fetch(req.query.imageUrl)// 攻击者传 http://169.254.169.254/latest/meta-data/ → 读取云主机元数据// 或 http://127.0.0.1:6379/ → 探测内网服务防御:
- URL 白名单(域名 + 协议),最有效
- 校验解析后的 IP,拒绝私有网段(
10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16) - 禁止跟随重定向(或限制重定向次数并重新校验)
- 出网请求走独立代理,限制可达范围
4.5 业务逻辑安全
这类漏洞没有统一规则,靠设计审查发现:
| 类型 | 示例 | 防御 |
|---|---|---|
| 金额篡改 | 前端传 price=1 下单 |
服务端以数据库价格为准,不信任客户端金额 |
| 数量为负 | 购买 quantity=-1 反而加钱 |
服务端校验取值范围 |
| 并发竞态 | 同时提交两次领券请求 | 唯一索引 + 分布式锁 / 乐观锁 |
| 优惠券叠加 | 重复使用同一张券 | 状态机 + 条件更新(WHERE status=0) |
| 越权改状态 | 直接调接口把订单改为「已支付」 | 状态流转由服务端控制,支付状态以回调为准 |
-- ✅ 用条件更新保证幂等与状态机正确UPDATE coupons SET status = 1, used_at = NOW()WHERE id = ? AND user_id = ? AND status = 0;-- 影响行数为 0 说明已被使用,直接拒绝五、配置与密钥管理
5.1 绝不硬编码密钥
// ❌ 危险:提交进 Git 后无法收回const SECRET = 'my-secret-key-123'// ✅ 环境变量const SECRET = process.env.JWT_SECRETif (!SECRET) throw new Error('缺少 JWT_SECRET')必须做的:
.env加入.gitignore- 提供
.env.example作为模板(不含真实值) - 生产密钥与开发密钥完全不同
- 密钥泄露后立即轮换(历史 Git 记录里的密钥依然有效)
5.2 生产环境关闭调试
# php.inidisplay_errors = Offlog_errors = Onexpose_php = Off// Nodeif (process.env.NODE_ENV === 'production') { app.on('error', (err) => logger.error(err)) // 不把堆栈返回给客户端}错误响应不要暴露内部信息:
// ❌ 把数据库错误原样返回ctx.body = { error: err.message } // 泄露表名、SQL 结构// ✅ 统一友好提示 + 内部记录logger.error({ err, requestId: ctx.state.requestId })ctx.body = { code: 500, message: '服务异常,请稍后重试', requestId: ctx.state.requestId }5.3 其他配置项
| 项 | 建议 |
|---|---|
| 目录遍历 | Nginx autoindex off(默认关闭,确认即可) |
| 隐藏版本号 | server_tokens off |
| 安全响应头 | X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Referrer-Policy: strict-origin-when-cross-origin |
| 敏感文件不可访问 | .git、.env、*.sql、*.bak 显式 deny |
| 数据库账号 | 不用 root;只授权必需库表;禁止 FILE、SUPER 权限 |
| Redis | 绑定内网、设置密码、禁用 FLUSHALL/CONFIG 等危险命令 |
# 常见敏感路径防护location ~ /\.(git|env|svn) { deny all; }location ~* \.(sql|bak|log|ini)$ { deny all; }六、传输安全与 HTTPS
| 项 | 要求 |
|---|---|
| 全站 HTTPS | HTTP 301 跳转到 HTTPS |
| HSTS | Strict-Transport-Security: max-age=31536000; includeSubDomains |
| 证书 | 正规 CA 签发,开启自动续期 |
| 加密套件 | 禁用 TLS 1.0/1.1,仅 1.2/1.3 |
| 混合内容 | 页面内不得引用 HTTP 资源 |
ssl_protocols TLSv1.2 TLSv1.3;ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;七、依赖与供应链安全
# 检查已知漏洞npm auditnpm audit fixcomposer audit # PHPmvn dependency-check # Java(OWASP Dependency-Check)| 措施 | 说明 |
|---|---|
| 锁定版本 | 提交 package-lock.json / composer.lock |
| 定期更新 | 至少每季度评估一次依赖升级 |
| 慎用冷门包 | 检查下载量、维护状态、issue 活跃度 |
| 私有源 | 企业环境用私有 registry,防止投毒 |
组件漏洞是被低估的风险源:框架本身的洞往往比业务代码更容易被批量利用。
八、日志与应急响应
8.1 日志该记什么
| 记录 | 不记录 |
|---|---|
| 登录成功/失败(含 IP、时间、UA) | 明文密码、完整身份证号 |
| 权限变更、敏感操作 | 完整银行卡号、CVV |
| 关键业务状态流转 | 完整 token / session 值 |
| 异常与错误(含 requestId) | 数据库连接串 |
// ✅ 脱敏后再记logger.info({ event: 'login', userId: user.id, phone: maskPhone(phone), // 138****0000 ip: ctx.ip, requestId: ctx.state.requestId,})8.2 安全事件响应顺序
- 止损:下线漏洞接口 / 封禁攻击 IP / 强制失效会话
- 取证:保留日志与现场,不要急着清空
- 评估影响:哪些数据被访问、是否有数据外泄
- 修复:修漏洞 + 轮换泄露的密钥
- 复盘:补测试与检查项,避免复发
九、上线前安全检查清单
输入与输出
- 所有 SQL 使用参数化查询或 ORM,无字符串拼接
- 排序/表名等标识符使用白名单
- 所有用户输入在服务端做类型、长度、范围校验
- 用户内容输出按上下文转义,富文本经白名单净化
- 前端不使用
innerHTML/eval处理不可信数据
认证授权
- 密码使用 bcrypt/argon2 存储
- 登录后重新生成 Session ID
- JWT 显式指定算法,密钥足够强且来自环境变量
- 所有涉及资源的查询都带上归属条件(防水平越权)
- 管理类接口有角色校验中间件(防垂直越权)
- 敏感操作(改密、支付、绑定)二次验证
其他
- Cookie 设置
HttpOnly/Secure/SameSite - CORS 使用域名白名单,未反射任意 Origin
- 上传做扩展名 + MIME + 魔数 + 大小校验,目录禁止执行脚本
- 服务端外发请求有 URL 白名单或私网地址拦截
- 生产环境关闭
display_errors,错误响应不含内部信息 -
.env、.git、备份文件不可通过 URL 访问 - 全站 HTTPS + HSTS
- 依赖已执行漏洞扫描
- 日志对敏感字段脱敏
十、常见误区
| 误区 | 事实 |
|---|---|
| 「前端已经校验了」 | 前端校验只是体验优化,攻击者可直接构造请求 |
| 「参数是加密的」 | 客户端加密的密钥也在客户端,等于没加密 |
| 「接口没暴露在页面上」 | 只要能被抓包或猜到,就算公开接口 |
| 「用了 ORM 就安全」 | ORM 的原生 SQL 方法依然可能被注入 |
| 「内网系统不用管安全」 | 内网横向移动是最常见的失陷路径 |
| 「HTTPS 就安全了」 | HTTPS 只保证传输加密,不防注入与越权 |
| 「小项目没人盯」 | 自动化扫描器不区分项目大小 |
十一、总结
安全的本质是不信任任何外部输入,并在每一层都做校验:
- 注入类(SQL/XSS/命令)→ 靠「参数化 + 转义 + 不用 shell」根治,不靠过滤关键字。
- 越权是在真实项目中出现频率最高的漏洞 → 靠「查询层带归属条件 + 统一权限中间件」解决。
- 业务逻辑漏洞没有通用规则 → 靠「服务端不信任客户端金额/数量/状态」的设计审查。
- 配置与密钥是最容易犯的低级错误 → 靠清单化检查(
.env不入库、关调试、目录防护)。
一句话判断标准:假设攻击者能看到你所有前端代码、能修改任何请求参数,你的服务端是否仍然安全?
完