安全审查
这一课你将能做到
- 在 Code Review 中识别 SQL 注入、越权访问、敏感信息泄露三类高危漏洞
- 判断加密、鉴权、审计日志的实现是否满足最小权限和可溯源要求
- 建立「安全红线」清单,把 P0 安全问题变成合并阻断项
为什么要学
安全漏洞的成本不是修复一行代码,而是「通知所有用户改密码 + 监管罚款 + 品牌信任崩塌」。很多安全 bug 极其容易审查——SQL 注入、硬编码密钥、接口没鉴权——但 review 时没人专门看安全视角。
人话:安全审查不需要你是安全专家,只需要你在 review 时多问几句「这里能不能被恶意利用」。
先建立图像
CR 安全审查的「红蓝对抗」视角
蓝方(防御) 红方(攻击者想做什么)
├─ SQL 全部参数化 → 注入数据库
├─ 接口入口鉴权(注解/拦截器) → 越权访问别人的数据
├─ 输出编码(防 XSS) → 把 JS 脚本塞给用户
├─ 密钥不落地代码 → 翻到密钥进生产环境
├─ 文件上传限制类型/大小 → 上传 webshell
├─ 日志脱敏 → 日志泄露用户隐私
└─ 操作审计日志 → 事后溯源
审查口诀:输入不轻信、输出必编码、鉴权在入口、密钥不落地。
跟着做
步骤 1:审 SQL 注入
❌ 反例:字符串拼接 SQL
@Select("SELECT * FROM course WHERE title LIKE '%" + keyword + "%'")
List<Course> search(String keyword);
攻击者输入 keyword = "'; DROP TABLE course; --",全站课程表消失。
✅ 正例:MyBatis #{} 预编译参数化
@Select("SELECT * FROM course WHERE title LIKE CONCAT('%', #{keyword}, '%')")
List<Course> search(@Param("keyword") String keyword);
// MyBatis XML 写法
<select id="search" resultType="Course">
SELECT * FROM course
WHERE title LIKE CONCAT('%', #{keyword}, '%')
</select>
审查动作:搜索 PR 里的所有 SQL,看有没有 ${}(字符串替换)或字符串拼接。 #{} 是参数绑定,安全;${} 是字符串替换,危险。
步骤 2:审接口鉴权
❌ 反例:接口没鉴权
@GetMapping("/api/v1/courses/{id}/students")
public List<UserVO> getStudents(@PathVariable Long id) {
// 没检查当前用户是否有权限查看这门课的学生
return courseService.getStudents(id);
}
攻击者遍历 id,拿到所有课程的所有学生名单。
✅ 正例:Spring Security + 业务层鉴权
@PreAuthorize("@courseAuthChecker.isOwner(#id, authentication.name)")
@GetMapping("/api/v1/courses/{id}/students")
public List<UserVO> getStudents(@PathVariable Long id) {
return courseService.getStudents(id);
}
或者统一在 Service 层兜底:
public List<UserVO> getStudents(Long courseId) {
Long currentUserId = SecurityUtils.getCurrentUserId();
Course course = courseMapper.selectById(courseId);
if (course == null || !course.getAuthorId().equals(currentUserId)) {
throw new AccessDeniedException("无权查看此课程的学生");
}
return studentMapper.listByCourse(courseId);
}
审查动作:看 Controller 每个接口有没有 @PreAuthorize、@Secured、或 Service 层权限检查。 特别是涉及「查看/修改别人的数据」的接口。
步骤 3:审敏感信息
@Value("${aliyun.accessKeySecret}")
private String accessKeySecret; // 密钥写在代码里或 properties 里提交到仓库
// 日志打印用户信息
log.info("用户登录 user={}", user); // 打印了 password 字段
// 错误响应把堆栈暴露出去
return Result.fail(e.getMessage() + " " + e.getStackTrace());
// 密钥走环境变量 / Vault / 阿里云 KMS
@Value("${ALIYUN_ACCESS_KEY_SECRET}") // 从环境变量注入,不提交到代码仓库
private String accessKeySecret;
// 日志脱敏
log.info("用户登录 userId={}", user.getId()); // 只打非敏感字段
// 错误响应只返回业务码
return ResponseEntity.status(500)
.body(new ErrorBody("INTERNAL_ERROR", "系统错误,请联系管理员", traceId));
- 搜索 PR 里的
password、secret、token、key 关键词
- 检查日志打印对象时是否脱敏
- 检查异常处理是否暴露内部信息
步骤 4:审 XSS 与输出编码
// Next.js 前端
function Comment({ content }) {
return <div>{content}</div>; // content 里塞 <script>alert(1)</script> → XSS
}
✅ 正例:React 默认转义 + 富文本用 DOMPurify
import DOMPurify from 'dompurify';
function Comment({ content }) {
// 纯文本:React 默认转义,安全
return <div>{content}</div>;
}
function RichComment({ htmlContent }) {
// 富文本:用 DOMPurify 过滤危险标签
const clean = DOMPurify.sanitize(htmlContent, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
审查动作:看到 dangerouslySetInnerHTML 时必须有 Purify;后端返回 HTML 时必须有转义或白名单。
步骤 5:审文件上传
@PostMapping("/upload")
public String upload(MultipartFile file) throws IOException {
File dest = new File("/uploads/" + file.getOriginalFilename());
file.transferTo(dest);
return "/uploads/" + file.getOriginalFilename();
}
攻击者上传 ../../etc/passwd 或 shell.jsp。
✅ 正例:限制扩展名、限制大小、重命名存储、非 Web 目录
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
String ext = FilenameUtils.getExtension(file.getOriginalFilename());
List<String> allowed = List.of("jpg", "png", "pdf");
if (!allowed.contains(ext.toLowerCase())) {
throw new BizException("不支持的文件类型");
}
if (file.getSize() > 10 * 1024 * 1024) {
throw new BizException("文件超过 10MB 限制");
}
String storedName = UUID.randomUUID() + "." + ext;
Path dest = Paths.get(uploadPath, storedName); // uploadPath 在配置里,不是 Web 目录
file.transferTo(dest);
return storedName;
}
讲清楚要点
- 安全审查的核心是「不信任任何输入」:不管是 URL 参数、请求体、文件、Header,都可能被攻击者控制。审查时对每个外部输入问「如果这里塞恶意内容会怎样」。
- 鉴权要在每个入口做:前端校验是防君子,后端校验才是防小人。所有涉及数据隔离的接口,后端必须有权限校验。
- 密钥不落地代码:代码仓库里不该有密码、密钥、Token。走环境变量、Vault、KMS。
- 日志是安全漏洞的重灾区:打
user 对象可能把密码带出来;打 SQL 可能把身份证号带出来。敏感字段必须脱敏。
- 安全红线就是 P0:安全漏洞没有任何「下次再修」的空间——一旦合入,风险就存在。
你可能会懵的地方
-
MyBatis ${} 什么时候能用?
只有「不是用户输入」的场景:比如 ORDER BY ${column} 里的 column 是后端枚举白名单控制的。审查时看到 ${} 先标红,再让作者证明「这个变量不可被用户控制」。
-
接口鉴权用 Spring Security 注解还是代码里写 if?
粗粒度(角色级别)用注解(@PreAuthorize("hasRole('ADMIN')"));细粒度(数据归属级别)用代码判断(当前用户是不是这条数据的 owner)。两者结合使用。
-
JWT Token 泄露了怎么办?
Token 不该出现在日志里(脱敏);Token 有效期要短(access token 15 分钟,refresh token 7 天);支持撤销(Redis 存黑名单或 token 版本号)。审查时看到 token 处理问这三个问题。
-
XSS 后端要不要防?
最佳实践是「输出端编码」:后端存原始数据,前端渲染时转义。如果后端也做过滤,可能导致数据被错误修改(比如用户故意写了 <> 当文本用)。审查时看渲染端有没有做编码。
-
敏感信息脱敏到什么程度?
日志里:手机号 138****8888、身份证 110**********1234、密码绝不出现;API 响应里:密码不回传(即使是当前用户自己的),密钥不回传;错误信息里:不暴露堆栈、不暴露数据库表结构。
错法对照
| 错法 | 问题 | 正解 |
|---|
| SQL 字符串拼接 | SQL 注入 | #{} 参数化 |
| 接口无鉴权 | 越权访问 | @PreAuthorize / 业务层校验 |
| 密钥写死在代码/配置 | 密钥泄露 | 环境变量 / Vault |
| 日志打印完整用户对象 | 隐私泄露 | 脱敏 / 只打非敏感字段 |
| 文件上传不限制类型/路径 | 上传 webshell | 白名单扩展名 + 重命名 + 非 Web 目录 |
卡点急救
-
怀疑 SQL 注入但不确定:用 SQLMap 或手动构造 ' OR '1'='1 payloads 测试。如果接口返回了不该返回的数据,就是注入。
-
接口太多,不知道哪个没鉴权:用 Burp Suite 或 Postman 批量遍历,用两个不同用户的 Token 调同一个接口看数据是否隔离。review 时优先审查「增删改 + 查别人的数据」类接口。
-
密钥已经泄露到仓库了怎么办:立刻 revoke(阿里云/AWS 控制台撤销密钥),然后历史提交也清理掉(BFG Repo-Cleaner 或 filter-branch)。
面试常问
-
Code Review 时怎么发现 SQL 注入?
搜所有 SQL:看有没有字符串拼接(+ 拼接 SQL)、${} 占位符、动态 SQL 里未白名单的 ORDER BY / GROUP BY 字段。MyBatis 里 #{} 是安全的(预编译),${} 是危险的(字符串替换)。
-
越权和未授权的区别?怎么防?
未授权 = 没登录就能调;越权 = 登录了但访问了别人的数据。防未授权用认证拦截器;防越权用数据归属校验(assert ownerId == currentUserId)。最容易漏的是「列表接口」——查询条件没限制 owner,导致能查别人的列表。
-
JWT 和 Session 各有什么安全问题?
JWT:不可撤销(除非额外做黑名单)、payload 可被客户端读取、体积大。Session:服务端集中存储,分布式下需要共享(Redis),但可撤销、可刷新。审查时 JWT 要问「怎么撤销被盗的 Token」;Session 要问「Session 固定攻击怎么防」。
自测三问
- MyBatis 里
#{} 和 ${} 在安全上的根本区别是什么?
- 为什么说「前端校验是防君子,后端校验才是防小人」?
- 日志里打印
user.toString() 可能泄露什么敏感信息?
小结一句话
安全审查的口诀是「输入不轻信、输出必编码、鉴权在入口、密钥不落地」——每处外部输入都假设它带着恶意。
延伸