Java Web应用CSRF防御实战:从原理到Spring Security实现

发布时间:2026/6/20 21:12:30
Java Web应用CSRF防御实战:从原理到Spring Security实现 1. 项目概述为什么CSRF依然是Web安全的“隐形杀手”做后端开发这些年和Web安全漏洞打交道是家常便饭。SQL注入、XSS这些名字大家耳熟能详防御意识也普遍上来了。但有一个漏洞它不像SQL注入那样能直接拖库也不像XSS那样能弹个窗那么直观却像幽灵一样潜伏在用户正常的操作背后悄无声息地完成攻击。这就是跨站请求伪造也就是CSRF。我见过太多项目认证授权做得挺复杂接口权限也分得很细但偏偏在这个看似“简单”的漏洞上栽了跟头。攻击者甚至不需要窃取你的密码就能让你在不知情的情况下以你的身份执行转账、改密、发帖等操作。今天我就结合自己踩过的坑和修复过的案例把CSRF从原理到Java实战防御掰开揉碎了讲清楚。无论你是刚入门的新手还是有一定经验的开发者理解并防御CSRF都是构建可靠Web应用的必修课。2. CSRF攻击原理深度拆解它到底是如何“冒充”你的要防御一个攻击首先得彻底理解它是怎么发生的。CSRF的核心在于“伪造”和“跨站”我们得把这两个词吃透。2.1 核心攻击模型与流程想象一个场景你登录了心爱的网上银行A站浏览器里保存着A站发给你的登录凭证比如Session Cookie。此时你在另一个标签页不小心点开了一个恶意网站B站。B站的页面上藏着一个看不见的表单或者一段自动执行的JavaScript代码这个代码会向A站的“转账”接口发起一个请求。因为你的浏览器在访问A站时已经携带了合法的CookieA站的后台服务器收到这个请求后一看Cookie是对的就会认为这是你本人发起的操作于是乖乖执行转账。整个过程你作为用户可能完全感知不到。这个攻击流程可以抽象为三个必要条件缺一不可用户已登录目标网站并持有有效的会话凭证通常是Cookie这是攻击能成功的“信任基础”。目标网站存在可被预测或猜测的操作接口比如/transfer?toattackeramount10000这样的GET请求或者一个没有防伪令牌的POST接口。用户被诱骗访问了恶意页面这个页面会自动或诱导用户向目标网站发起请求。这里有个关键点CSRF攻击的是用户浏览器与服务器之间的“信任关系”而不是直接窃取数据。攻击者无法通过CSRF拿到你的Session ID或响应内容但他可以“借用”你的身份去执行操作。2.2 常见攻击载体与代码示例攻击者会利用哪些方式来发起伪造的请求呢主要有以下几种理解它们有助于我们在防御时有的放矢。1. 自动提交的HTML表单GET/POST这是最经典的方式。恶意页面里嵌入一个隐藏的form用JavaScript在页面加载时自动提交。!-- 假设银行转账接口为GET请求这是极度危险的设计 -- img srchttp://bank.com/transfer?toattackeramount10000 styledisplay:none; !-- 或者使用表单 -- form idcsrf-form actionhttp://bank.com/transfer methodPOST styledisplay:none; input typehidden nameto valueattacker input typehidden nameamount value10000 /form script document.getElementById(csrf-form).submit(); /script只要用户访问这个页面转账请求就发出去了。如果接口是GET一个img标签就能触发因为浏览器会自动加载图片的src。2. 通过JavaScript发起AJAX请求受同源策略限制但仍有方法现代浏览器严格的同源策略会阻止跨域的AJAX请求读取响应但请求本身依然可能被发送出去对于某些不关心响应结果的操作如注销登录POST /logout攻击可能生效。更危险的是如果目标网站配置了宽松的CORS策略如Access-Control-Allow-Origin: *那么跨域AJAX就能读取响应危害更大。// 简单fetch请求即使跨域请求也可能发出 fetch(http://bank.com/transfer, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({to: attacker, amount: 10000}), // 浏览器默认会携带该域名下的Cookie credentials: include });3. 利用JSONP等历史遗留特性在过去为了跨域获取数据JSONP曾被广泛使用。它通过script标签加载一个返回JavaScript代码的URL。如果目标站点存在JSONP接口且该接口能执行敏感操作攻击者就可以构造恶意URL诱使用户访问。script srchttp://bank.com/api/transfer?callbackmalicioustoattackeramount10000/script当浏览器加载这个脚本时就会向银行发起一个携带用户Cookie的GET请求。虽然现在JSONP已不推荐使用但在一些老系统中仍可能遇到。注意理解这些载体后你会发现一个关键——攻击请求是从用户的浏览器发往目标服务器的来源是用户的IP。服务器日志里看到的完全是正常用户的访问记录这给事后追溯带来了极大困难。3. Java Web应用中的CSRF防御方案实战知道了攻击原理我们就可以针对性布防。防御的核心思想是打破攻击的三个必要条件之一通常我们选择在服务器端验证请求的“真实性”确保它来自我们自己的应用页面。下面介绍几种在Java Web项目中主流的、可落地的防御方案。3.1 同步令牌模式最经典可靠的方案这是防御CSRF的基石也被称为“Anti-CSRF Token”。其原理是服务器在渲染表单或页面时生成一个随机、不可预测的令牌Token将其存放在服务器的Session或分布式缓存中同时将这个令牌作为隐藏字段嵌入表单。当用户提交表单时必须将这个令牌一并提交回来。服务器收到请求后比对提交的令牌和Session中存储的是否一致以此判断请求的合法性。为什么有效恶意网站无法提前知晓或获取到这个随机令牌因为同源策略限制了它读取目标站点的页面内容因此它构造的伪造请求中无法包含有效的令牌服务器验证就会失败。Java Servlet 示例实现我们从一个最简单的ServletJSP场景开始理解整个流程。// Token生成与验证工具类 public class CSRFTokenUtil { private static final String CSRF_TOKEN_SESSION_ATTR csrfToken; // 生成令牌并存入Session public static String generateToken(HttpSession session) { String token UUID.randomUUID().toString(); session.setAttribute(CSRF_TOKEN_SESSION_ATTR, token); return token; } // 验证令牌 public static boolean isValidToken(HttpServletRequest request) { HttpSession session request.getSession(false); if (session null) { return false; } String sessionToken (String) session.getAttribute(CSRF_TOKEN_SESSION_ATTR); String requestToken request.getParameter(csrfToken); // 从请求参数获取 if (sessionToken null || requestToken null) { return false; } // 安全地比较字符串避免时序攻击 return MessageDigest.isEqual(sessionToken.getBytes(), requestToken.getBytes()); } // 使用后使令牌失效一次性令牌增强安全性 public static void invalidateToken(HttpSession session) { session.removeAttribute(CSRF_TOKEN_SESSION_ATTR); } }%-- 在JSP表单中嵌入令牌 --% form action/transfer methodpost input typehidden namecsrfToken value${sessionScope.csrfToken} 收款人: input typetext nametobr 金额: input typenumber nameamountbr input typesubmit value转账 /form// 在Servlet中验证令牌 WebServlet(/transfer) public class TransferServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) { if (!CSRFTokenUtil.isValidToken(request)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, 无效的CSRF令牌); return; } // 令牌验证通过执行核心业务逻辑转账 String to request.getParameter(to); String amount request.getParameter(amount); // ... 业务处理 ... // 业务完成后可以使当前令牌失效强制下一个表单使用新令牌 // CSRFTokenUtil.invalidateToken(request.getSession()); } }实操心得与注意事项令牌的存储与生命周期令牌通常存储在用户会话中。对于分布式应用需要确保会话是共享的如使用Spring Session Redis否则用户请求被负载均衡到不同服务器会导致验证失败。令牌的绑定更安全的做法是将令牌与用户身份甚至具体操作绑定。例如token HMAC_SHA256(sessionId “/transfer”)这样即使令牌泄露也只能用于特定端点。一次性使用 vs 多次使用上述示例在验证后使令牌失效实现了“一次性”安全性最高但可能会影响浏览器的“后退”操作或多标签操作。折中方案是让令牌在一个会话周期内有效或为每个表单生成独立的令牌。AJAX请求的处理对于前端通过AJAX提交的请求需要将令牌放在请求头中如X-CSRF-TOKEN而不是请求体。因为同源策略恶意网站无法设置自定义请求头。// 前端从meta标签或初始数据中获取令牌 var token document.querySelector(meta[namecsrf-token]).getAttribute(content); fetch(/api/transfer, { method: POST, headers: { Content-Type: application/json, X-CSRF-TOKEN: token // 放在请求头 }, body: JSON.stringify(data) });// 后端Servlet同时检查参数和请求头 String requestToken request.getParameter(csrfToken); if (requestToken null) { requestToken request.getHeader(X-CSRF-TOKEN); } // 然后进行验证3.2 使用框架内置支持以Spring Security为例如果你使用Spring Boot/Spring Security防御CSRF几乎可以“开箱即用”但必须理解其默认行为并正确配置。Spring Security的CSRF防护机制Spring Security默认会为每个会话生成一个名为_csrf的令牌。它提供了自动令牌管理CsrfTokenRepository负责生成、保存、加载令牌。自动令牌注入在Thymeleaf、JSP等模板中使用_csrf变量或form:form标签会自动添加隐藏字段。请求验证过滤器CsrfFilter会拦截POST,PUT,PATCH,DELETE等非安全方法GET,HEAD,TRACE,OPTIONS通常被放行验证令牌。基础配置在Spring Security配置类中CSRF防护默认是启用的。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .and() // CSRF防护默认是开启的无需显式调用 .csrf() // 但如果需要禁用极不推荐可以 .csrf().disable() .csrf() // 显式配置csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()); // 示例使用Cookie存储令牌 } }与前端如React/Vue的集成Spring Security默认期望令牌以参数_csrf或请求头X-CSRF-TOKEN的形式提交。获取令牌首先需要从后端获取一个令牌。可以创建一个接口返回令牌或者在登录后的页面中通过隐藏字段或Meta标签提供。RestController public class CsrfController { GetMapping(/csrf-token) public CsrfToken csrf(CsrfToken token) { return token; // Spring会自动注入 } }前端存储与发送前端在初始化时调用此接口获取令牌然后将其存储在内存或非HttpOnly的Cookie中并在后续所有非安全请求的头部携带。// 以Axios为例 import axios from axios; // 获取令牌并设置为默认请求头 axios.get(/csrf-token).then(response { const token response.data.token; // 假设返回{token: “xxx”} axios.defaults.headers.common[X-CSRF-TOKEN] token; });踩坑记录登出Logout请求Spring Security的默认登出端点/logout是POST请求需要CSRF令牌。如果你的前端是单页面应用用GET /logout可能更方便但安全性稍低。可以在配置中指定登出请求的HTTP方法。http.logout() .logoutRequestMatcher(new AntPathRequestMatcher(/logout, GET)); // 改为GET排除某些API对于提供给第三方调用的纯API接口无状态使用Token如JWT认证CSRF防护是不必要的因为CSRF依赖于浏览器Cookie。可以使用csrf().ignoringAntMatchers(/api/**)来排除。CookieCsrfTokenRepository的使用这个策略将令牌放在Cookie里名为XSRF-TOKEN前端JavaScript可以读取并设置到请求头。这简化了前后端分离架构下的集成但要注意设置httpOnlyfalse以便JS读取同时务必确保你的站点没有XSS漏洞否则令牌会被窃取。3.3 双重Cookie验证与同源检测除了令牌模式还有一些辅助或替代方案。双重Cookie验证思路很简单。在用户访问站点时后端在响应中设置一个Cookie例如CSRF-TOKENrandom_value。前端JavaScript读取这个Cookie的值并在发起请求时将其作为参数或自定义请求头如X-CSRF-TOKEN一起发送。后端同时验证请求中的Cookie值和参数/头部的值是否一致。优点实现相对简单适合前后端分离且同域的场景。致命缺点如果网站存在XSS漏洞攻击者可以轻易读取Cookie值从而构造出有效的请求。因此它不能作为主要的防御手段只能作为补充或在特定受控环境下使用。检查请求头中的Origin/RefererHTTP请求头中的Origin或Referer字段可以表明请求的来源页面。服务器可以验证这些字段是否来自预期的域名。String referer request.getHeader(Referer); String origin request.getHeader(Origin); if (referer ! null !referer.startsWith(https://your-trusted-domain.com)) { // 拒绝请求 }优点实现简单无需改变现有表单。局限性隐私与兼容性用户浏览器可能禁用发送Referer头。Origin头在CORS复杂请求和POST请求中会出现但简单场景如表单提交可能没有。可能被绕过在某些古老的浏览器或配置不当时攻击者可能篡改这些请求头尽管现代浏览器禁止JavaScript设置这些头。更常见的问题是如果应用允许空Referer比如从本地文件打开或某些安全策略下攻击者可以构造一个没有Referer的请求。判断逻辑复杂需要处理null值、解析URL、比对域名和协议容易出错。结论检查Origin/Referer可以作为一种深度防御Defense in Depth的补充手段但绝不能作为唯一的防御措施。4. 从开发到部署CSRF防御全流程实操指南理解了方案我们需要把它融入到开发的每一个环节形成习惯和规范。4.1 开发阶段安全编码习惯养成区分安全方法与非安全方法严格遵守RESTful规范或HTTP语义。GET、HEAD等请求应该用于获取资源永不用于执行会改变服务器状态的操作如创建、更新、删除。将状态修改操作限定在POST、PUT、PATCH、DELETE方法上这样CSRF防护框架如Spring Security可以天然地区分并保护它们。为每一个状态修改端点添加CSRF防护无论是表单提交、AJAX调用还是API请求只要会改变数据就必须经过CSRF令牌验证。在代码审查时将此作为硬性检查点。前端统一请求拦截器在前端项目中使用Axios Interceptor或Fetch Wrapper自动为所有非安全请求添加CSRF令牌头避免开发人员遗漏。// axios拦截器示例 axios.interceptors.request.use(config { const method config.method.toUpperCase(); if ([POST, PUT, PATCH, DELETE].includes(method)) { const token getCSRFToken(); // 从Cookie或Store获取 config.headers[X-CSRF-TOKEN] token; } return config; });4.2 测试阶段如何验证防护是否生效不能光靠“我觉得应该没问题”必须进行测试。手动测试登录你的应用。打开浏览器开发者工具复制一个执行敏感操作如修改个人资料的请求为cURL命令。新开一个无痕窗口确保没有登录Cookie直接运行这个cURL命令。请求应该失败返回403Forbidden或类似的错误。在已登录的窗口尝试修改请求中的CSRF令牌为一个错误的值提交。请求应该失败。自动化安全测试将CSRF漏洞扫描纳入CI/CD流水线。可以使用OWASP ZAP、Burp Suite等工具的自动化扫描功能或者使用像csrf-tester这样的专用库进行单元/集成测试。渗透测试如果条件允许定期邀请安全团队或使用第三方服务进行黑盒/白盒渗透测试CSRF是必测项目。4.3 部署与运维配置与监控会话管理配置确保会话Cookie设置了Secure仅HTTPS传输、HttpOnly禁止JavaScript访问防XSS窃取、SameSite属性。SameSite属性是防御CSRF的强力补充。将其设置为Strict或Lax可以阻止大多数跨站请求携带Cookie。Strict浏览器在任何跨站情况下都不会发送Cookie。Lax默认值在安全的顶级导航如点击链接时会发送Cookie但跨站的子资源请求如图片、iframe、AJAX不会发送。这平衡了安全性和用户体验。 在Spring Boot中可以在application.properties中配置server.servlet.session.cookie.same-sitelax监控与告警在应用日志或监控系统中关注403状态码的异常增长。一个正常的用户很少会触发CSRF验证失败。如果短时间内出现大量403错误且Referer头指向未知或可疑域名很可能正在遭受CSRF攻击探测需要立即排查。保持依赖更新确保你使用的安全框架如Spring Security是最新的稳定版本以获取最新的安全修复和最佳实践。5. 进阶议题与疑难问题排查在实际项目中总会遇到一些边界情况或复杂场景。5.1 单页面应用与无状态API的CSRF防护SPA RESTful API 架构下常用JWTJSON Web Token进行无状态认证。此时由于不依赖服务器端的Session传统的基于Session的CSRF令牌机制需要调整。场景用户登录后后端返回一个JWT前端将其存储在内存或本地存储并在每次请求时放在Authorization头中。攻击者能否发起CSRF分析关键在于认证凭证的存储位置。如果JWT只存在内存或LocalStorage中浏览器不会自动将其附加到跨站请求上因此传统的CSRF攻击无效。因为攻击者无法通过恶意网站窃取这些凭证受同源策略保护。但是如果为了便利将JWT也放在了Cookie中比如用于服务端渲染那么CSRF风险就又回来了。结论与建议纯Token方案推荐坚持将JWT放在HTTP头部如Authorization: Bearer token不要将其存入Cookie。这样天然免疫基于Cookie的CSRF。如果需要Cookie例如你的SPA和一个后端渲染的旧系统共享域名不得不使用Cookie。那么你必须为所有状态修改端点启用CSRF防护。由于无状态你不能用Session存令牌。可以采用加密的Cookie令牌生成一个随机令牌加密后设置在Cookie中HttpOnlyfalse以便前端读取同时要求前端在请求头中回传该令牌。后端解密并比对。确保加密密钥的安全。Double Submit Cookie如前所述设置一个随机值的Cookie前端读取后作为请求头或参数发送。后端验证两者是否一致。这需要防范XSS。5.2 文件上传接口的CSRF防护文件上传通常是一个POST请求但enctypemultipart/form-data。传统的在表单里加一个隐藏字段csrfToken的方式仍然有效。但是在服务器端解析时要注意顺序。常见坑点一些解析文件上传的库如Apache Commons FileUpload或框架组件需要先解析完整个请求体才能获取到所有参数。如果你在过滤器中先尝试读取csrfToken参数可能会因为请求体尚未被解析而获取不到导致验证失败。解决方案将令牌放在URL查询参数或请求头中这是最干净的方法。例如前端在上传前先获取一个令牌然后以X-CSRF-TOKEN请求头的方式发送或者附加在URL后/upload?csrfTokenxxx。确保验证逻辑在文件解析之后在Spring Security中CsrfFilter默认在安全过滤器链中位置靠前。如果使用MultipartFilter需要确保CsrfFilter在MultipartFilter之后执行这样请求体已被解析才能获取到表单字段。在Spring Boot中通常已自动配置好。// 手动调整过滤器顺序的示例通常不需要 http.addFilterAfter(new CsrfFilter(...), MultipartFilter.class);5.3 常见问题排查速查表遇到CSRF相关问题时可以按以下思路排查问题现象可能原因排查步骤与解决方案登录后操作频繁报403 “Invalid CSRF Token”1. 令牌未正确生成或存储。2. 前端未正确发送令牌。3. 会话丢失或不一致分布式环境。1.检查后端调试生成令牌的代码确认令牌已存入Session或Redis。检查Session ID是否稳定。2.检查前端用浏览器开发者工具查看请求确认csrfToken参数或X-CSRF-TOKEN请求头存在且值正确。3.检查会话配置确认分布式Session配置正确所有节点能访问同一Session存储。仅在某些浏览器或环境下失败1. 浏览器安全策略如Cookie的SameSite设置。2. 前端代码兼容性问题。1.检查Cookie查看浏览器Application面板确认认证Cookie和CSRF相关Cookie已成功设置且没有异常的SameSite或Secure限制。2.检查请求头对比成功和失败的请求查看Origin,Referer,Cookie等头的差异。AJAX请求成功但表单提交失败或反之1. 令牌获取/放置的位置不一致。2. 框架对不同类型的请求处理不同。1.统一令牌来源确保表单和AJAX从同一个地方如meta标签获取令牌。2.统一提交方式表单提交和AJAX提交令牌应放在相同的地方都是参数或都是请求头。Spring Security默认检查参数_csrf和头X-CSRF-TOKEN。开启了CSRF防护后第三方应用无法调用我们的APIAPI接口错误地受到了CSRF保护。正确区分Web界面和API使用Spring Security的ignoringAntMatchers排除API路由。确保API使用如JWT、API Key等无状态认证方式而非Cookie。5.4 一个真实的案例绕过“错误”的Referer检查我曾审计过一个老系统它通过检查Referer头是否包含自身域名来防御CSRF。看起来没问题对吧但它的验证逻辑是这样的if (referer ! null referer.contains(my-domain.com)) { // 通过验证 }这里存在一个逻辑漏洞contains检查是不安全的。攻击者可以注册一个域名如www.attackermy-domain.com然后在自己的页面上构造指向https://www.my-domain.com/transfer的恶意请求。此时Referer是https://www.attackermy-domain.com/它包含了字符串my-domain.com于是通过了检查修复方案必须进行严格的域名和协议HTTPS比对。public static boolean isValidReferer(HttpServletRequest request, String expectedDomain) { String referer request.getHeader(Referer); if (referer null) { // 根据策略决定是否允许空Referer通常严格模式下不允许 return false; } try { URI refererUri new URI(referer); String host refererUri.getHost(); // 确保协议是HTTPS且主机名完全匹配或子域名匹配根据需求 return https.equalsIgnoreCase(refererUri.getScheme()) host ! null (host.equals(expectedDomain) || host.endsWith(. expectedDomain)); } catch (URISyntaxException e) { return false; } }这个案例告诉我们安全验证的每一个细节都必须严谨模糊匹配、字符串包含等操作在安全领域往往是危险的源头。

月新闻