Web 应用安全评审模板:从输入到权限逐项检查

0阅读10分钟

安全评审的起点不是漏洞清单,是数据流

每次做安全评审,我习惯先画一张数据流图:用户输入从哪里进来,经过哪些处理环节,最终存到哪个数据库或发给哪个第三方服务。沿着这条路径走一遍,大部分高风险问题会自然浮出来。

单独拿一份漏洞清单逐项打勾,容易变成走过场。清单本身没有错,但评审人和业务上下文之间缺少关联——你知道 SQL 注入是高风险,却不一定能指出这个项目的哪段代码在处理原始查询。

OWASP Top 10 在 2025 年做了一次比较大的调整。访问控制缺陷(Broken Access Control)继续排第一,安全配置错误(Security Misconfiguration)从第 5 升到第 2,供应链问题(Software Supply Chain Failures)作为新类别直接进入第 3,注入攻击(Injection)降到第 5。这份排名变化反映了一个现实:基础设施层面的注入防御已经相对成熟,但权限边界、依赖管理和配置治理依然是大多数团队的短板。

这篇文章把安全评审拆成几个可以落地的模块,每个模块配具体代码和检查标准。你可以直接拿来当团队评审模板用。

评审框架:按数据流阶段划分

一次完整的安全评审,我通常按下面四个阶段依次检查:

流程图画布 · 115%
Mermaid 流程图加载中...

这个结构不是死的,但它能帮你建立「输入在哪、风险在哪」的直觉。下面按每个阶段分别讲。

输入入口:校验不是前端的事

案例 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 ≥ 12HTTPS不记录
手机号/身份证AES-256 加密存储HTTPS脱敏显示(138****1234
支付信息不走自有系统,用支付网关 tokenHTTPS + PCI DSS只记录交易 ID
会话 TokenHttpOnly + Secure + SameSite CookieHTTPS只记录前 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 auditsnyk 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 的清单是通用参考,具体到你的项目,需要结合业务场景判断优先级——支付系统的输入校验和日志脱敏肯定比内容展示站更关键。

几个经验性的判断:

  1. 权限问题永远排第一。OWASP Top 10 连续两届把 Broken Access Control 放首位,现实中这也是代码审查最容易漏掉的。输入校验和 SQL 注入有成熟的工具防,权限校验没有。
  2. 日志脱敏要提前约定格式。等项目上线后再去排查日志里的明文密码,成本远高于在设计阶段就确定脱敏规则。
  3. 依赖安全不是一次性工作。供应链攻击在 2024-2025 年密集爆发,OWASP 把它排到第 3 不是偶然。把依赖扫描嵌入 CI,每次 PR 自动检查,比手动定期扫描靠谱。
  4. Webhook 签名验证别省。第三方回调是外部系统主动推数据给你,和 API 调用的安全假设不同。不验证签名,等于在公网上开了一个无认证的写入入口。

把这份检查清单放进 Code Review 流程,每次上线前对着过一遍,比事后补救成本低得多。

参考资料

评论 0

0 / 1000