全栈手账 订阅 RSS

Web 安全实战

Web 安全实战手册:梳理 OWASP Top 10 威胁,覆盖 SQL 注入、XSS、CSRF、越权、文件上传、密钥管理等场景,每种攻击给出原理、示例与防御方案,并附上线前检查清单。

读了 28 分钟 更新于 11 月 3 日

一、威胁模型与安全基线

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 语句的一部分执行。

php
// ❌ 危险:字符串拼接$sql = "SELECT * FROM users WHERE username = '" . $_GET['name'] . "'";// 输入 ' OR '1'='1  →  SELECT * FROM users WHERE username = '' OR '1'='1'// 输入 '; DROP TABLE users; --  →  直接删表

防御:参数化查询(唯一正解)

php
// ✅ PDO 预处理$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ?');$stmt->execute([$name]);
java
// ✅ JDBC PreparedStatementString sql = "SELECT * FROM users WHERE username = ?";try (PreparedStatement ps = conn.prepareStatement(sql)) {    ps.setString(1, name);    ResultSet rs = ps.executeQuery();}
javascript
// ✅ mysql2 占位符const [rows] = await pool.query('SELECT * FROM users WHERE username = ?', [name]);
javascript
// ✅ ORM(TypeORM / Prisma / Sequelize)自动参数化await userRepo.findOne({ where: { username: name } });

常见误区:

误区 说明
用 addslashes() 转义 无法覆盖所有字符集与场景,仍可绕过
用正则过滤关键字 变形绕过(大小写、注释、编码)防不住
只对数字字段做 intval() 字符串字段依然危险
ORM 就绝对安全 使用原生 SQL 拼接(query() 字符串拼接)时依然会被注入

ORDER BY / LIMIT 场景:占位符不能用于标识符,必须用白名单:

javascript
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 命令。

javascript
// ❌ 危险:shell 拼接const { exec } = require('child_process')exec(`convert ${userFile} -resize 800x800 out.png`)// 输入 "a.jpg; rm -rf /"  →  灾难

防御:避免 shell,用参数数组

javascript
// ✅ execFile / spawn:不经过 shellconst { execFile } = require('child_process')execFile('convert', [userFile, '-resize', '800x800', 'out.png'])
php
// ✅ 每个参数单独转义$cmd = 'convert ' . escapeshellarg($file) . ' -resize 800x800 out.png';

根本建议:能用库就不调命令行。图片处理用 sharp / Pillow / GD,不必调用 convert。

2.3 XSS(跨站脚本)

三种类型:

类型 触发方式 典型场景
存储型 恶意脚本存进数据库,其他用户浏览时执行 评论、昵称、个人简介
反射型 脚本在 URL 中,服务端原样回显 搜索关键词回显、错误页
DOM 型 前端 JS 把不可信数据写进 DOM innerHTML、document.write

防御一:输出转义(按上下文)

javascript
// ✅ HTML 文本上下文function escapeHtml(s) {  return String(s)    .replace(/&/g, '&amp;')    .replace(/</g, '&lt;')    .replace(/>/g, '&gt;')    .replace(/"/g, '&quot;')    .replace(/'/g, '&#039;')}

不同上下文需要不同转义:HTML 文本、HTML 属性、JS 字符串、URL 参数、CSS——混用会失效。

html
<!-- ❌ 属性上下文用 HTML 转义不够 --><a href="javascript:alert(1)">链接</a><!-- ✅ URL 参数要先做协议白名单 + URL 编码 -->

防御二:避免危险 API

javascript
// ❌ 危险el.innerHTML = userContentdocument.write(userContent)eval(userContent)// ✅ 安全el.textContent = userContentel.setAttribute('data-x', userContent)

防御三:富文本用白名单净化

javascript
// 引入 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(内容安全策略)

nginx
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 加固

nginx
Set-Cookie: session=xxx; HttpOnly; Secure; SameSite=Lax; Path=/

HttpOnly 让 JS 读不到 Cookie,即使 XSS 成功也拿不到会话。


三、认证与授权

3.1 密码存储

绝对不要:明文、MD5、SHA1、单次 SHA256(都可被彩虹表或 GPU 暴力破解)。

javascript
// ✅ bcryptconst bcrypt = require('bcrypt')const hash = await bcrypt.hash(password, 12)          // 存储const ok = await bcrypt.compare(inputPwd, hash)       // 校验
php
// ✅ PHP 内置$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);if (password_verify($inputPwd, $hash)) { /* 通过 */ }
java
// ✅ 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 编码,不是加密——任何人都能解开看内容。

javascript
// ❌ 常见错误:把敏感信息塞进 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
javascript
// ✅ 签发与校验(显式指定算法)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 安全

javascript
// ✅ 关键配置app.use(session({  key: 'sid',  secret: process.env.SESSION_SECRET,   // 不硬编码  httpOnly: true,  secure: true,                          // 仅 HTTPS  sameSite: 'lax',  maxAge: 7 * 24 * 3600 * 1000,}))

登录后必须重新生成 Session ID(防会话固定攻击):

javascript
req.session.regenerate((err) => {  req.session.userId = user.id   // 在再生成之后写入})

3.4 越权(最高频的线上漏洞)

两类越权:

类型 含义 示例
水平越权 同角色间越界访问他人数据 改 orderId=1001 为 1002 看到别人的订单
垂直越权 低权限用户执行高权限操作 普通用户直接调 /api/admin/users

根因:只校验了「是否登录」,没校验「这个资源是不是你的」。

javascript
// ❌ 只验登录,不验归属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})

防御要点:

  1. 查询层做归属过滤,而不是查出来再判断——避免遗漏。
  2. 返回 404 而非 403:不泄露资源是否存在。
  3. 垂直越权用统一中间件:按角色声明路由所需权限,不靠每个接口手写。
  4. ID 用不可枚举的标识(如 UUID/雪花 ID),降低遍历风险——但这不是防御手段,只是降低危害。

四、其他高频风险

4.1 CSRF(跨站请求伪造)

原理:用户已登录站点 A,访问恶意站点 B,B 自动向 A 发起请求,浏览器自动带上 A 的 Cookie。

防御三件套:

javascript
// 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 配置错误

javascript
// ❌ 危险:反射任意 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 文件上传

四层校验,缺一不可:

javascript
// 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
# Nginx:上传目录禁止执行 PHPlocation ^~ /uploads/ {    location ~ \.(php|php5|phtml)$ { deny all; }}

4.4 SSRF(服务端请求伪造)

原理:服务端根据用户输入去请求 URL,攻击者诱导它访问内网。

javascript
// ❌ 危险:用户可控 URLconst data = await fetch(req.query.imageUrl)// 攻击者传 http://169.254.169.254/latest/meta-data/  → 读取云主机元数据// 或 http://127.0.0.1:6379/  → 探测内网服务

防御:

  1. URL 白名单(域名 + 协议),最有效
  2. 校验解析后的 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)
  3. 禁止跟随重定向(或限制重定向次数并重新校验)
  4. 出网请求走独立代理,限制可达范围

4.5 业务逻辑安全

这类漏洞没有统一规则,靠设计审查发现:

类型 示例 防御
金额篡改 前端传 price=1 下单 服务端以数据库价格为准,不信任客户端金额
数量为负 购买 quantity=-1 反而加钱 服务端校验取值范围
并发竞态 同时提交两次领券请求 唯一索引 + 分布式锁 / 乐观锁
优惠券叠加 重复使用同一张券 状态机 + 条件更新(WHERE status=0)
越权改状态 直接调接口把订单改为「已支付」 状态流转由服务端控制,支付状态以回调为准
sql
-- ✅ 用条件更新保证幂等与状态机正确UPDATE coupons SET status = 1, used_at = NOW()WHERE id = ? AND user_id = ? AND status = 0;-- 影响行数为 0 说明已被使用,直接拒绝

五、配置与密钥管理

5.1 绝不硬编码密钥

javascript
// ❌ 危险:提交进 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 生产环境关闭调试

ini
# php.inidisplay_errors = Offlog_errors = Onexpose_php = Off
javascript
// Nodeif (process.env.NODE_ENV === 'production') {  app.on('error', (err) => logger.error(err))   // 不把堆栈返回给客户端}

错误响应不要暴露内部信息:

javascript
// ❌ 把数据库错误原样返回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 等危险命令
nginx
# 常见敏感路径防护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 资源
nginx
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;

七、依赖与供应链安全

bash
# 检查已知漏洞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) 数据库连接串
javascript
// ✅ 脱敏后再记logger.info({  event: 'login',  userId: user.id,  phone: maskPhone(phone),    // 138****0000  ip: ctx.ip,  requestId: ctx.state.requestId,})

8.2 安全事件响应顺序

  1. 止损:下线漏洞接口 / 封禁攻击 IP / 强制失效会话
  2. 取证:保留日志与现场,不要急着清空
  3. 评估影响:哪些数据被访问、是否有数据外泄
  4. 修复:修漏洞 + 轮换泄露的密钥
  5. 复盘:补测试与检查项,避免复发

九、上线前安全检查清单

输入与输出

  • 所有 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 只保证传输加密,不防注入与越权
「小项目没人盯」 自动化扫描器不区分项目大小

十一、总结

安全的本质是不信任任何外部输入,并在每一层都做校验:

  1. 注入类(SQL/XSS/命令)→ 靠「参数化 + 转义 + 不用 shell」根治,不靠过滤关键字。
  2. 越权是在真实项目中出现频率最高的漏洞 → 靠「查询层带归属条件 + 统一权限中间件」解决。
  3. 业务逻辑漏洞没有通用规则 → 靠「服务端不信任客户端金额/数量/状态」的设计审查。
  4. 配置与密钥是最容易犯的低级错误 → 靠清单化检查(.env 不入库、关调试、目录防护)。

一句话判断标准:假设攻击者能看到你所有前端代码、能修改任何请求参数,你的服务端是否仍然安全?


完

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。