从开发视角看安全:我的Spring Boot项目是如何一步步堵上SQL注入、XSS、越权这些坑的
在开发一个用户管理系统的过程中,安全问题往往不是一开始就考虑周全的。作为后端开发者,我们更关注功能的快速实现和性能优化,直到某次安全扫描报告亮起红灯,或是线上出现安全事件,才会真正重视起来。本文将分享我在开发Spring Boot项目时,如何从零开始逐步完善安全防护,解决SQL注入、XSS、越权等常见漏洞的真实经历。
1. SQL注入:从拼接SQL到预编译的转变
最初实现用户查询功能时,为了快速上线,我直接使用了字符串拼接的方式构建SQL语句:
// 危险示例:字符串拼接SQL @GetMapping("/users") public List<User> getUsers(@RequestParam String name) { String sql = "SELECT * FROM users WHERE name = '" + name + "'"; return jdbcTemplate.query(sql, new UserRowMapper()); }直到安全团队用简单的测试用例name=admin' OR '1'='1就获取了全部用户数据,我才意识到问题的严重性。修复方案很简单但极其有效:
// 安全方案:使用预编译语句 @GetMapping("/users") public List<User> getUsers(@RequestParam String name) { String sql = "SELECT * FROM users WHERE name = ?"; return jdbcTemplate.query(sql, new Object[]{name}, new UserRowMapper()); }在MyBatis中,同样需要注意${}和#{}的区别:
<!-- 危险用法 --> <select id="findByName" parameterType="String" resultType="User"> SELECT * FROM users WHERE name = '${name}' </select> <!-- 安全用法 --> <select id="findByName" parameterType="String" resultType="User"> SELECT * FROM users WHERE name = #{name} </select>关键防御措施:
- 始终使用预编译语句(PreparedStatement)
- MyBatis中优先使用
#{}语法 - 对必须使用动态表名/列名的场景,严格白名单校验
- 最小化数据库账号权限
2. XSS防护:从基础转义到内容安全策略
用户评论功能上线后不久,运营同事报告有用户昵称显示异常。检查发现有人提交了这样的内容:
<script>alert('XSS攻击')</script>第一层防御是在服务端对输出进行HTML转义:
// Spring Boot默认的Thymeleaf模板已自动转义 // 手动转义示例: import org.springframework.web.util.HtmlUtils; public String safeOutput(String input) { return HtmlUtils.htmlEscape(input); }但转义并不能解决所有场景。比如用户需要在评论中使用富文本时,我们采用了更精细的防护:
- 引入JSoup库进行HTML过滤
// 只允许安全的HTML标签和属性 String safeHtml = Jsoup.clean(unsafeHtml, Whitelist.basicWithImages() .addAttributes("a", "href", "title") .addProtocols("a", "href", "http", "https"));- 设置HTTP安全头增强防护
// 在Spring Security配置中添加 http.headers() .xssProtection() .contentSecurityPolicy("default-src 'self'; script-src 'self' 'unsafe-inline'");多维度防护方案:
- 响应头设置:X-XSS-Protection, Content-Security-Policy
- 前端框架如React/Vue的自动转义
- 富文本场景使用白名单过滤
- Cookie设置HttpOnly属性
3. 越权漏洞:从简单判断到系统化权限控制
最初的用户信息接口只验证了用户是否登录,没有校验操作的资源是否属于当前用户:
// 危险示例:未校验用户权限 @GetMapping("/users/{userId}") public User getUser(@PathVariable Long userId) { return userRepository.findById(userId).orElse(null); }这导致了水平越权漏洞——任何登录用户只需修改userId参数就能查看他人信息。修复方案:
// 基础权限校验 @GetMapping("/users/{userId}") public User getUser(@PathVariable Long userId, @AuthenticationPrincipal User currentUser) { if (!currentUser.getId().equals(userId)) { throw new AccessDeniedException("无权访问该用户信息"); } return userRepository.findById(userId).orElse(null); }随着业务复杂化,我们引入了Spring Security的权限系统:
// 基于注解的细粒度控制 @PreAuthorize("hasRole('ADMIN') or #userId == principal.id") @GetMapping("/users/{userId}") public User getUser(@PathVariable Long userId) { // ... } // 方法级权限控制 @PreAuthorize("@permissionService.canAccessUser(principal, #userId)") @GetMapping("/users/{userId}/details") public UserDetail getUserDetails(@PathVariable Long userId) { // ... }权限控制最佳实践:
- 遵循最小权限原则
- 服务端校验永远比前端校验可靠
- 对敏感操作记录详细日志
- 定期审计权限配置
4. CSRF防护:从手动Token到框架集成
在发现通过伪造请求可以执行用户非预期的操作后,我们首先手动实现了CSRF Token:
<!-- 前端表单中添加Token --> <form action="/transfer" method="post"> <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/> <!-- 其他表单字段 --> </form>// 服务端校验 @PostMapping("/transfer") public void transferMoney(@Valid TransferRequest request, @RequestParam("_csrf") String csrfToken) { // 验证CSRF Token if (!csrfToken.equals(session.getAttribute("CSRF_TOKEN"))) { throw new SecurityException("Invalid CSRF token"); } // 处理业务逻辑 }后来发现Spring Security已经提供了完善的CSRF防护,只需简单配置:
@Override protected void configure(HttpSecurity http) throws Exception { http .csrf() .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // 其他配置... }CSRF防护要点:
- 重要操作使用POST/PUT/DELETE方法
- 同源策略检查
- 敏感操作二次认证
- 避免GET请求修改状态
5. 文件上传安全:从简单后缀检查到全流程防护
用户头像上传功能最初只检查了文件后缀:
// 不安全的检查方式 String ext = FilenameUtils.getExtension(filename); if (!Arrays.asList("jpg", "png").contains(ext)) { throw new IllegalArgumentException("不支持的文件类型"); }这种防护很容易绕过,我们升级为更全面的检查:
- 文件内容类型验证
// 使用Tika检测真实文件类型 InputStream is = file.getInputStream(); ContentType contentType = new Tika().detect(is); if (!"image/jpeg".equals(contentType.toString())) { throw new IllegalArgumentException("非图片文件"); }- 文件存储安全处理
// 生成随机文件名并限制访问权限 String safeFilename = UUID.randomUUID() + ".jpg"; Path dest = Paths.get("/var/upload", safeFilename); Files.copy(file.getInputStream(), dest, StandardCopyOption.REPLACE_EXISTING); // 设置文件权限 Files.setPosixFilePermissions(dest, PosixFilePermissions.fromString("rw-r-----"));- 服务端图片二次处理
// 使用Thumbnailator重写图片文件 Thumbnails.of(inputStream) .size(200, 200) .outputFormat("jpg") .toOutputStream(outputStream);文件上传安全措施:
- 文件类型白名单校验
- 文件内容真实检测
- 随机化存储文件名
- 限制文件访问权限
- 图片文件重压缩处理
- 独立文件服务器部署
6. 日志与监控:安全问题的最后防线
即使做了各种防护,仍然需要完善的日志系统作为最后保障。我们在关键位置添加了安全日志:
@Aspect @Component public class SecurityLogAspect { @AfterReturning( pointcut = "execution(* com.example..*(..)) && @annotation(securityLog)", returning = "result") public void logAfter(JoinPoint joinPoint, SecurityLog securityLog, Object result) { String method = joinPoint.getSignature().toShortString(); String params = Arrays.toString(joinPoint.getArgs()); // 记录到专门的审计日志 auditLogger.info("操作[{}], 参数[{}], 结果[{}]", method, params, result); } @AfterThrowing( pointcut = "execution(* com.example..*(..))", throwing = "ex") public void logException(JoinPoint joinPoint, Throwable ex) { if (ex instanceof AccessDeniedException) { securityAlert.warn("权限拒绝访问: {}", joinPoint.getSignature(), ex.getMessage()); } } }同时配置了ELK日志系统,设置关键安全事件的告警规则:
# 示例告警规则 alert: name: "多次登录失败" condition: > count by "source_ip" ( status == "FAILURE" and event == "LOGIN_ATTEMPT" ) > 5 action: > notify_security_team(source_ip)安全是一个持续的过程,需要开发者在每个功能迭代中都保持警惕。从我的经验来看,最有效的安全策略不是复杂的防护体系,而是开发团队对安全问题的持续关注和及时响应。