Web 应用安全评审模板:从输入到权限逐项检查
安全评审的起点不是漏洞清单,是数据流
每次做安全评审,我习惯先画一张数据流图:用户输入从哪里进来,经过哪些处理环节,最终存到哪个数据库或发给哪个第三方服务。沿着这条路径走一遍,大部分高风险问题会自然浮出来。
单独拿一份漏洞清单逐项打勾,容易变成走过场。清单本身没有错,但评审人和业务上下文之间缺少关联——你知道 SQL 注入是高风险,却不一定能指出这个项目的哪段代码在处理原始查询。
OWASP Top 10 在 2025 年做了一次比较大的调整。访问控制缺陷(Broken Access Control)继续排第一,安全配置错误(Security Misconfiguration)从第 5 升到第 2,供应链问题(Software Supply Chain Failures)作为新类别直接进入第 3,注入攻击(Injection)降到第 5。这份排名变化反映了一个现实:基础设施层面的注入防御已经相对成熟,但权限边界、依赖管理和配置治理依然是大多数团队的短板。
这篇文章把安全评审拆成几个可以落地的模块,每个模块配具体代码和检查标准。你可以直接拿来当团队评审模板用。
评审框架:按数据流阶段划分
一次完整的安全评审,我通常按下面四个阶段依次检查:
这个结构不是死的,但它能帮你建立「输入在哪、风险在哪」的直觉。下面按每个阶段分别讲。
输入入口:校验不是前端的事
案例 1:表单字段跳过后端直接写库
场景:一个后台管理系统的「编辑用户资料」页面,前端用表单限制了手机号只能输入数字、长度 11 位。后端接口直接取 request.body.phone 拼进 SQL 查询。
翻车:前端校验只在浏览器生效。攻击者用 curl 或 Burp Suite 直接发 POST 请求,phone 字段传入 1' OR '1'='1,查询返回所有用户数据。
修复:后端必须独立做输入校验,并且使用参数化查询。
// ❌ 错误做法:前端校验后直接拼 SQL
app.post('/api/users', async (req, res) => {
const { phone } = req.body
// 信任前端传来的数据,直接拼字符串
const result = await db.query(`SELECT * FROM users WHERE phone = '${phone}'`)
res.json(result)
})
// ✅ 正确做法:后端独立校验 + 参数化查询
import { z } from 'zod'
const phoneSchema = z.string().regex(/^1[3-9]\d{9}$/)
app.post('/api/users', async (req, res) => {
const parsed = phoneSchema.safeParse(req.body.phone)
if (!parsed.success) {
return res.status(400).json({ error: '手机号格式不合法' })
}
// 参数化查询,即使校验被绕过也不会注入
const result = await db.query('SELECT * FROM users WHERE phone = $1', [parsed.data])
res.json(result)
})输入校验的核心原则只有一条:所有外部输入必须在服务端用白名单规则校验。OWASP 安全编码指南明确要求"Conduct all input validation on a trusted system",这里的 trusted system 就是你的后端服务。
| 校验策略 | 说明 | 安全性 | 适用场景 |
|---|---|---|---|
| 白名单校验 | 只允许符合规则的输入 | 高 | 所有用户输入的首选方案 |
| 黑名单过滤 | 拒绝已知危险字符 | 中低 | 仅作为补充,不可单独使用 |
| 前端校验 | 浏览器/客户端校验 | 低 | 仅提升用户体验,不能替代后端 |
| 编码/转义 | 对输出上下文做编码 | 高 | 与白名单配合用于 HTML/SQL/URL 上下文 |
文件上传:白名单扩展名 + 内容检测
文件上传是输入入口中容易被忽视的环节。仅检查文件扩展名不够,MIME 类型也可以伪造。
// ❌ 错误做法:只检查扩展名
app.post('/api/upload', upload.single('avatar'), (req, res) => {
const ext = path.extname(req.file.originalname).toLowerCase()
if (ext !== '.jpg' && ext !== '.png') {
return res.status(400).json({ error: '不支持的文件类型' })
}
// 攻击者把 shell.php 改名为 shell.jpg 就能绕过
fs.rename(req.file.path, `./uploads/${req.file.originalname}`)
})
// ✅ 正确做法:扩展名 + MIME 类型 + 文件头 magic bytes 三重检查
const ALLOWED_TYPES = [
{ ext: '.jpg', mime: 'image/jpeg', magic: Buffer.from([0xFF, 0xD8, 0xFF]) },
{ ext: '.png', mime: 'image/png', magic: Buffer.from([0x89, 0x50, 0x4E, 0x47]) },
]
async function validateFile(file: Express.Multer.File) {
const ext = path.extname(file.originalname).toLowerCase()
const type = ALLOWED_TYPES.find(t => t.ext === ext)
if (!type || type.mime !== file.mimetype) return false
const fd = await fs.promises.open(file.path, 'r')
const buf = Buffer.alloc(4)
await fd.read(buf, 0, 4, 0)
await fd.close()
return buf.equals(type.magic)
}服务端处理:鉴权和权限是两件事
案例 2:水平越权——换个 ID 就看别人的订单
场景:订单详情接口 GET /api/orders/:id,服务端检查了用户是否登录,但没有验证这个订单是不是属于当前用户。
翻车:登录用户 A 把 URL 中的 orderId 从 1001 改成 1002,就能看到用户 B 的订单详情,包括收货地址、电话和支付信息。
修复:每次查询都加上用户归属条件。
// ❌ 错误做法:只检查登录,不检查归属
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id])
// 任何登录用户都能查看任意订单
res.json(order)
})
// ✅ 正确做法:查询条件绑定当前用户 ID
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
)
if (!order) {
return res.status(404).json({ error: '订单不存在' })
}
res.json(order)
})这个漏洞在 OWASP Top 10 2025 中排第一(Broken Access Control),连续两届没挪窝。原因很直白:权限校验逻辑散落在各个 Controller 里,没有统一抽象,时间一长就有人漏写。
垂直越权和水平越权的区别:
| 类型 | 含义 | 典型场景 | 防御要点 |
|---|---|---|---|
| 水平越权 (IDOR) | 同权限级别用户互相访问 | 用户 A 查看用户 B 的订单 | 查询条件绑定 user_id |
| 垂直越权 | 低权限用户执行高权限操作 | 普通用户调用管理员删除接口 | 路由级别角色检查中间件 |
| 未授权访问 | 未登录直接访问受保护资源 | 直接访问 /api/admin/users | 全局 auth 中间件 + 默认拒绝 |
案例 3:权限中间件写错位置导致管理接口暴露
场景:一个后台系统有 /api/admin/* 系列接口,团队在路由文件中给部分接口加了 adminOnly 中间件,但漏掉了几个新增的接口。
翻车:新增的 DELETE /api/admin/users/:id 没有挂 adminOnly,普通登录用户就能调用。
修复:用路由分组统一挂载权限中间件,而不是逐个接口添加。
// ❌ 错误做法:逐个接口加权限中间件,容易遗漏
app.delete('/api/admin/users/:id', adminOnly, async (req, res) => { /* ... */ })
app.get('/api/admin/logs', adminOnly, async (req, res) => { /* ... */ })
// 新增接口时忘记加 adminOnly
app.post('/api/admin/config', async (req, res) => { /* 任何人都能改配置 */ })
// ✅ 正确做法:路由分组统一挂载
const adminRouter = Router()
adminRouter.use(adminOnly) // 所有 admin 路由共享权限检查
adminRouter.delete('/users/:id', async (req, res) => { /* ... */ })
adminRouter.get('/logs', async (req, res) => { /* ... */ })
adminRouter.post('/config', async (req, res) => { /* 自动继承 adminOnly */ })
app.use('/api/admin', adminRouter)数据存储:加密、脱敏、日志三件套
数据存下来之后的安全问题集中在三个点:敏感字段是否加密、日志是否脱敏、错误信息是否泄露内部细节。
会话管理:Token 的生命周期
会话管理是鉴权之后最容易出问题的环节。OWASP 安全编码指南要求"Session identifier creation must always be done on a trusted system",这意味着 Token 或 Session ID 的生成、验证和销毁都必须在服务端完成。
// ❌ 错误做法:客户端生成 Token + 退出不清理会话
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await verifyUser(email, password)
if (!user) return res.status(401).json({ error: '登录失败' })
// 用时间戳做 Token,可预测
const token = Buffer.from(`${user.id}:${Date.now()}`).toString('base64')
res.json({ token })
})
// 退出接口只清除前端 Cookie,服务端 session 仍然有效
app.post('/logout', (req, res) => {
res.clearCookie('token')
res.json({ message: '已退出' })
})
// ✅ 正确做法:crypto.randomBytes 生成 + 退出时服务端销毁
import crypto from 'crypto'
app.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await verifyUser(email, password)
if (!user) return res.status(401).json({ error: '登录失败' })
const token = crypto.randomBytes(32).toString('hex')
await redis.set(`session:${token}`, user.id, 'EX', 86400) // 24 小时过期
res.cookie('session_id', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 86400 * 1000,
})
})
app.post('/logout', async (req, res) => {
const token = req.cookies.session_id
if (token) {
await redis.del(`session:${token}`) // 服务端销毁
}
res.clearCookie('session_id')
res.json({ message: '已退出' })
})敏感数据处理
// ❌ 错误做法:明文存密码 + 日志打印完整请求体
const hashedPassword = crypto.createHash('md5').update(password).digest('hex')
logger.info('用户登录', { email, password }) // 日志里明文密码
// ✅ 正确做法:bcrypt + 日志脱敏
import bcrypt from 'bcrypt'
const hashedPassword = await bcrypt.hash(password, 12)
logger.info('用户登录', {
email,
password: '***', // 敏感字段必须脱敏
})| 数据类型 | 存储要求 | 传输要求 | 日志要求 |
|---|---|---|---|
| 密码 | bcrypt/argon2 哈希,cost ≥ 12 | HTTPS | 不记录 |
| 手机号/身份证 | AES-256 加密存储 | HTTPS | 脱敏显示(138****1234) |
| 支付信息 | 不走自有系统,用支付网关 token | HTTPS + PCI DSS | 只记录交易 ID |
| 会话 Token | HttpOnly + Secure + SameSite Cookie | HTTPS | 只记录前 8 位 |
OWASP Top 10 2025 把「安全日志与告警失败」(Security Logging and Alerting Failures)排在第 9,比 2021 年的第 10 位略有下降,但这不意味着它不重要——日志和告警是安全事件的最后一道防线,出了问题才发现没有日志,或者日志里全是明文密码,那时候就晚了。
外部集成:Webhook 验证和第三方回调
Webhook 和第三方回调是另一个容易被忽视的入口。攻击者可以伪造回调请求,你的系统如果不验证来源,就可能被注入假数据。
我在一个支付对接项目中见过这种情况:开发团队认为回调 URL 本身足够保密,不需要额外验证。结果攻击者通过扫描拿到回调地址后,用假数据更新了大量订单状态。事后排查发现,回调日志里连请求来源 IP 都没有记录。
// ❌ 错误做法:不验证签名直接处理回调
app.post('/webhook/payment', (req, res) => {
const { orderId, status } = req.body
if (status === 'success') {
updateOrderStatus(orderId, 'paid') // 任何人都能伪造这个请求
}
res.status(200).send('ok')
})
// ✅ 正确做法:验证 HMAC 签名
import crypto from 'crypto'
app.post('/webhook/payment', (req, res) => {
const signature = req.headers['x-payment-signature'] as string
const expected = crypto
.createHmac('sha256', process.env.PAYMENT_WEBHOOK_SECRET!)
.update(JSON.stringify(req.body))
.digest('hex')
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).json({ error: '签名无效' })
}
// 签名验证通过,处理业务逻辑
updateOrderStatus(req.body.orderId, 'paid')
res.status(200).send('ok')
})注意 timingSafeEqual 的使用。普通的字符串比较 === 在遇到不匹配时会提前返回,执行时间差异可以被用来逐字节猜测签名。这不是理论风险,是真实可用的攻击手段。
供应链:2025 年的新必查项
OWASP Top 10 2025 新增的「软件供应链失败」直接排到第 3,反映了近两年供应链攻击的爆发。评审时需要检查:
1. 依赖是否有已知漏洞 → npm audit / snyk test
2. 是否锁定了依赖版本 → 检查 lock 文件提交情况
3. 是否使用 lock 文件 → package-lock.json / pnpm-lock.yaml
4. CI 是否安装未经审计的包 → 构建日志检查
5. 是否维护 SBOM(软件物料清单) → 至少知道用了哪些第三方
供应链安全的核心思路是「不信任外部输入,即使是代码包」。和输入校验的哲学一致,区别在于校验对象从用户数据变成了第三方依赖。
| 检查项 | 工具 | 频率 | 输出 |
|---|---|---|---|
| 依赖漏洞扫描 | npm audit / snyk / trivy | 每次 PR + 每周定时 | 漏洞报告 |
| Lock 文件完整性 | CI 脚本检查 | 每次 PR | 构建通过/失败 |
| 镜像基础层漏洞 | trivy / grype | 每次镜像构建 | 漏洞报告 |
| 密钥泄露检测 | gitleaks / trufflehog | 每次 commit (pre-commit hook) | 泄露告警 |
| 依赖来源验证 | npm provenance / Sigstore | 构建时 | 签名验证结果 |
安全评审检查清单
按评审阶段分组,每项标注检查方式。评审时对照打勾即可。
阶段一:准备(评审前 1 天)
- 画出系统数据流图,标注所有外部输入入口
- 梳理权限角色列表(管理员、普通用户、访客、API 调用方)
- 导出依赖清单,运行一次
npm audit或snyk test - 确认环境变量配置文件不含有实际密钥
阶段二:输入与鉴权(核心代码审查)
- 所有用户输入在服务端做白名单校验(不依赖前端校验)
- 数据库查询全部使用参数化查询或 ORM,无字符串拼接
- 文件上传有扩展名 + MIME + magic bytes 三重校验
- 所有受保护接口有 auth 中间件,管理接口用路由分组挂载
- 水平越权防护:数据查询绑定
user_id或归属字段 - 垂直越权防护:角色检查覆盖所有敏感操作
阶段三:数据与日志(存储层审查)
- 密码使用 bcrypt/argon2 存储,cost 因子 ≥ 12
- 敏感字段(手机号、身份证)加密存储
- 日志中无明文密码、Token、信用卡号等敏感信息
- 错误响应不泄露堆栈信息、数据库结构、内部路径
- HTTPS 全站强制,HSTS 头已配置
阶段四:外部集成与部署(边界检查)
- Webhook 回调验证签名(HMAC),使用
timingSafeEqual - 第三方 API 密钥存放在 vault 或环境变量,不硬编码
- CI/CD 流程有依赖锁文件检查
- 生产环境和开发环境的密钥/数据库完全隔离
阶段五:收尾(输出评审报告)
- 整理发现的问题,按严重性分级(Critical / High / Medium / Low)
- Critical 和 High 问题指定修复负责人和截止日期
- Medium 和 Low 问题记录到 backlog,纳入后续迭代
- 安排复评时间,确认修复完成
总结
安全评审模板的价值不是让你记住所有漏洞类型,而是给你一个结构化的路径,沿着数据流从头走到尾,每个阶段该看什么、怎么验证、什么算通过。OWASP 的清单是通用参考,具体到你的项目,需要结合业务场景判断优先级——支付系统的输入校验和日志脱敏肯定比内容展示站更关键。
几个经验性的判断:
- 权限问题永远排第一。OWASP Top 10 连续两届把 Broken Access Control 放首位,现实中这也是代码审查最容易漏掉的。输入校验和 SQL 注入有成熟的工具防,权限校验没有。
- 日志脱敏要提前约定格式。等项目上线后再去排查日志里的明文密码,成本远高于在设计阶段就确定脱敏规则。
- 依赖安全不是一次性工作。供应链攻击在 2024-2025 年密集爆发,OWASP 把它排到第 3 不是偶然。把依赖扫描嵌入 CI,每次 PR 自动检查,比手动定期扫描靠谱。
- Webhook 签名验证别省。第三方回调是外部系统主动推数据给你,和 API 调用的安全假设不同。不验证签名,等于在公网上开了一个无认证的写入入口。
把这份检查清单放进 Code Review 流程,每次上线前对着过一遍,比事后补救成本低得多。
参考资料
- OWASP Top 10:2025 — OWASP 官方最新版十大 Web 安全风险
- OWASP Secure Coding Practices Checklist — OWASP 安全编码实践快速参考指南
- OWASP Web Security Testing Guide — Web 安全测试方法论完整指南
- Web Application Security Checklist: 10 Best Practices — StackHawk — 10 项 Web 安全最佳实践清单
- OWASP Top 10 详解(2026 指南)— Plexicus — OWASP Top 10 每个风险的修复方法
- NCSC: Input Validation — Securing HTTP-based APIs — 英国国家网络安全中心输入校验指南