1. CSRF攻击的底层逻辑为什么你的系统至今还可能裸奔先别急着写代码把CSRF攻击的本质看透了后续防御体系的决策才不会被各种碎片化方案带偏。CSRFCross-Site Request Forgery跨站请求伪造发生在受害者已经登录可信站点的情况下攻击者诱导受害者的浏览器向可信站点发起请求。这个攻击成立的核心前提有三个浏览器会自动携带Cookie、很多系统只依赖Cookie识别用户身份、攻击者可以构造出完全符合业务预期的合法请求。打个比方CSRF攻击就好比你把手写签名留在了办事窗口然后有人拿着这张签了名的空白纸让工作人员误以为每一份新文件都是你亲自签发的。注意攻击者不需要看到你的签名内容他只需要借用你已经签过名这个状态。很多开发人员觉得CSRF已经过时了理由是同源策略不是已经限制了跨域读取吗这里的关键在于CSRF攻击不要求攻击者读取响应他只需要让请求被执行。同源策略限制的是读取CSRF利用的是执行。更改密码、转账、修改邮箱、发布内容这些都是典型的写操作攻击者根本不需要知道结果只要请求被成功处理就达到目的了。更麻烦的是现代企业级Java应用大多基于Spring Boot/Spring Security构建微服务架构下网关层、认证中心、各业务服务之间层层透传如果只在某一个节点做了CSRF防护其他入口依然是敞开的。我见过不少系统管理后台用了Spring Security默认自带的CSRF防护但对外开放的API网关完全没做任何校验结果有人直接通过网关接口伪造请求操作了后台数据。这种部分防御等于没防御的情况才是企业级系统面临的最大隐患。2. 方案选型为什么同步器令牌模式依然是企业级首选2.1 JPEG文件恢复流程这里我先纠正一个误区CSRF防御方案不是越复杂越好而是要跟系统架构形态匹配。目前业界主流的CSRF防御思路有几种我逐个说下取舍逻辑。**同步器令牌模式Synchronizer Token Pattern**是目前企业级应用最常用的方案。核心思想是服务端生成一个随机Token存储在Session中页面每次提交表单或发送Ajax请求时带上这个Token服务端对比请求中的Token与Session中的Token是否一致。Token对攻击者不可见所以攻击者构造的请求无法携带正确Token请求自然被拒绝。**双重提交Cookie模式Double Submit Cookie**的逻辑是不用服务端存储Token而是在用户访问时下发一个随机Token到Cookie中前端读取Cookie值放入请求参数或Header中服务端对比Cookie中的Token和请求中携带的Token是否一致。它的优势是无状态适合水平扩展的分布式架构。但它的弱点也很明显如果子域或相关域存在Cookie写入漏洞攻击者可以自己种下Token值从而绕过校验。SameSite Cookie属性是目前浏览器层面提供的防线设置SameSiteLax或SameSiteStrict后跨站请求就不会携带Cookie。但问题是浏览器兼容性虽然越来越好仍有老旧浏览器和特殊场景不生效而且企业级系统往往有跨站跳转、OAuth回调、第三方支付回调等场景SameSite一旦设置了Strict这些合法跨站流程会被活活卡死。我自己的项目里有一个用户从手机浏览器跳转小程序登录的场景SameSiteStrict直接导致登录态丢失排查了整整一天才定位到。自定义请求头校验如X-Requested-With逻辑最简单后端判断请求头是否存在且值为期望值。但它的安全性完全依赖浏览器跨域请求是否会携带自定义请求头这个事实。实际上用FetchAPI是可以通过mode: no-cors发起简单请求的此时自定义请求头会被剥掉反而让校验失效。这个方案只能作为辅助手段。综合对比下来企业级Java应用最可靠的路径是以同步器令牌模式为核配合Service端聚合校验Origin和Referer再结合业务场景针对性地开启Cookie的SameSite属性。这样即使某一层被绕过其他层依然能兜底。2.2 同步器令牌的生成与存储基于排错与性能的权衡选定了同步器令牌模式接下来要回答两个具体问题Token怎么生成存在哪里Token生成建议使用加密安全的随机数生成器Java里就是SecureRandom。有一个反面教材我印象很深某个项目用UUID.randomUUID()生成Token初看没问题但UUID的随机性来源在某些JDK版本上并非加密安全而且UUID.randomUUID()中包含了机器网卡信息旧版实现攻击者如果拿到多个Token样本理论上存在预测风险。企业级安全这块没必要在这种基础环节省事SecureRandom就是标准答案。Token存储的位置需要根据系统架构决定。单体应用直接放HttpSession即可Spring Security默认就是这样的实现。微服务架构下如果Session做了分布式共享比如通过Redis统一存储那Token仍然可以放在Session里。如果是真正的无状态JWT认证体系Session本身不存在此时Token可以选择放Redis带过期时间或者退而求其次用隐藏表单域Header双通道传递由服务端做二次动态校验。这里我想强调一个常被忽略的点Token必须绑定用户会话。如果Token是一个全局固定值所有用户共用那就等于没有防御。正确做法是每个会话生成唯一Token用户重新登录后必须重新生成。另外Token的过期时间也要设置一般建议跟随Session的过期时间Session失效后Token同步失效。我见过有的系统Token不设过期时间用户登录态早就过期了Token还能继续使用这相当于给攻击者留了一个永不关闭的后门。3. 从零构建CSRF防御核心过滤器、令牌管理与校验链3.1 定义CSRF令牌管理组件先设计一个核心组件统管Token的生成、获取和校验。这一步是整个防御体系的地基后面所有环节都围绕它展开。Component public class CsrfTokenManager { private static final String CSRF_TOKEN_ATTR CSRF_TOKEN; private final SecureRandom secureRandom new SecureRandom(); public String generateAndStoreToken(HttpServletRequest request) { HttpSession session request.getSession(); String token (String) session.getAttribute(CSRF_TOKEN_ATTR); if (token null) { token generateToken(); session.setAttribute(CSRF_TOKEN_ATTR, token); } return token; } public String getTokenFromSession(HttpServletRequest request) { HttpSession session request.getSession(false); return session null ? null : (String) session.getAttribute(CSRF_TOKEN_ATTR); } public boolean validateToken(HttpServletRequest request, String tokenFromRequest) { String sessionToken getTokenFromSession(request); if (sessionToken null || tokenFromRequest null) { return false; } // 恒定时间比较避免时序侧信道攻击 return MessageDigest.isEqual( sessionToken.getBytes(StandardCharsets.UTF_8), tokenFromRequest.getBytes(StandardCharsets.UTF_8) ); } private String generateToken() { byte[] bytes new byte[32]; secureRandom.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } }明确几个设计决策背后的原因。Token用32字节随机数做Base64编码长度约为43个字符安全性足够。为什么不用16字节企业级系统我会更保守一点32字节在可预见的未来完全够用而且Base64编码后长度也不会对请求造成实质性负担。校验时用MessageDigest.isEqual做恒定时间比较这是很多人会忽视的细节。普通的equals方法在首个不同字符处就会返回理论上可以通过响应时间差推断Token的具体内容。实际利用难度虽然极高但既然Java生态早就提供了恒定时间比较的API顺手用上就行。Token绑定Session存储天然享受了Session的过期机制。用户登出后执行session.invalidate()Token随之失效不需要额外做清理。3.2 核心CSRF过滤器的完整实现过滤器是整个防御体系的执行中枢。它的职责分三步判断请求是否需要校验、从请求中提取Token、决定拒绝还是放行。Component public class CsrfFilter implements Filter { private static final SetString SAFE_METHODS Set.of(GET, HEAD, OPTIONS, TRACE); private static final String CSRF_TOKEN_NAME X-CSRF-Token; private final CsrfTokenManager csrfTokenManager; public CsrfFilter(CsrfTokenManager csrfTokenManager) { this.csrfTokenManager csrfTokenManager; } Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) servletRequest; HttpServletResponse response (HttpServletResponse) servletResponse; String method request.getMethod().toUpperCase(); if (SAFE_METHODS.contains(method)) { chain.doFilter(servletRequest, servletResponse); return; } String tokenFromRequest extractTokenFromRequest(request); boolean valid csrfTokenManager.validateToken(request, tokenFromRequest); if (!valid) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\message\:\CSRF token validation failed\}); return; } chain.doFilter(servletRequest, servletResponse); } private String extractTokenFromRequest(HttpServletRequest request) { String token request.getHeader(CSRF_TOKEN_NAME); if (token null) { token request.getParameter(CSRF_TOKEN_NAME); } return token; } }关键判断为什么GET、HEAD、OPTIONS、TRACE不做校验因为CSRF攻击的核心目标是制造状态变更而HTTP规范中这几个方法是安全方法不应产生副作用。如果业务上出现了GET接口改数据的情况那是接口设计问题不应该靠CSRF过滤器来兜底。提取Token时同时考虑Header和参数是为了兼容不同前端形态。传统表单提交时Token放在隐藏域中提交时以参数形式到达后端现代Ajax请求则普遍放在自定义Header中。两种都支持前端接入时就有更大的选择空间。但从安全角度讲Header方案比参数方案安全性更好因为参数可能出现在日志里存在泄露风险。对非法请求返回403而不是302重定向到登录页是有意的设计。CSRF校验失败意味着请求来源可疑这时候引导用户走登录流程反而可能被攻击者利用来做钓鱼/误导直接返回明确错误码让前端自行处理错误提示是更干净的做法。3.3 请求级监控与辅助校验层仅仅校验Token还不够需要在过滤器链上增加一层辅助校验把Origin和Referer的校验作为启动条件。这层校验的意义在于即使Token校验被绕过比如某个接口漏配了拦截规则攻击者也需要满足来源校验才能成功执行请求。public class CsrfOriginChecker { private static final SetString ALLOWED_ORIGINS Set.of( https://admin.example.com, https://api.example.com ); public boolean check(HttpServletRequest request) { // 同源请求下Origin头可能为空此时用Referer兜底 String origin request.getHeader(Origin); if (origin ! null) { return ALLOWED_ORIGINS.contains(origin); } String referer request.getHeader(Referer); if (referer null || referer.isBlank()) { // 移动端App、自动化测试工具等场景没有Referer是正常的 // 此时跳过来源校验交给Token校验兜底 return true; } try { URL refererUrl new URL(referer); String target request.getRequestURL().toString(); URL targetUrl new URL(target); return refererUrl.getHost().equals(targetUrl.getHost()); } catch (MalformedURLException e) { return false; } } }这里有几个细节值得展开。第一Origin头在跨站请求中几乎总是存在所以优先级更高配置白名单时只保留企业自己的域名任何不在白名单里的来源一律拒绝。第二Referer校验不是校验严格等于某个域名而是校验Host一致因为子域跳转、端口变化是业务常态。第三Referer为空不能直接拒绝——很多企业微信内嵌浏览器、桌面客户端、单元测试框架发请求时压根不带Referer一概拒绝会把合法流量全打死所以管控策略的设计必须围绕分层兜底进行Token校验仍然是最终底牌。这套辅助校验层从实际经验看非常值得投入。我之前接手过一个历史系统安全扫描报告里明确标记了CSRF风险排查发现某第三方接入方的回调接口没有被CSRF过滤器覆盖但它的回调请求会携带固定的Referer信息。加上这层校验后该入口的风险等级直接从高危降为可接受。4. 前后端联调与多端适配让防御体系不卡在接入层4.1 传统服务端渲染页面的Token注入路径如果你的系统还在用JSP、Thymeleaf这类服务端渲染方式Token注入请求的路径比较直接。核心思路是在渲染页面时把Token同时写入页面隐藏域和页面可读取的JavaScript全局变量中。以Thymeleaf为例可以在Controller层通过Model把Token传进去Controller public class PageController { private final CsrfTokenManager csrfTokenManager; public PageController(CsrfTokenManager csrfTokenManager) { this.csrfTokenManager csrfTokenManager; } GetMapping(/admin/dashboard) public String dashboard(HttpServletRequest request, Model model) { String token csrfTokenManager.generateAndStoreToken(request); model.addAttribute(csrfToken, token); return admin/dashboard; } }页面里这样注入隐藏域form th:action{/admin/user/update} methodpost input typehidden th:nameX-CSRF-Token th:value${csrfToken} / input typetext nameusername / button typesubmit更新用户/button /form前后端低代码情况下方案本身没有问题但有一个高频坑表单中未使用Thymeleaf动态渲染的模板Token会直接把空串写进隐藏域。我之前遇到过页面静态化改造后模板里的Thymeleaf表达式被误删了结果提交表单时Token是空的用户被强制下线反馈量很大。这里核心的防护思路是主动进行安全扫码在测试环境专门跑一批自动化脚本检查所有渲染页面是否都正确输出了Token别再靠肉眼一遍遍看。Ajax场景下推荐在页面加载后统一设置jQuery或Axios的默认Header避免每个请求手动携带$.ajaxSetup({ headers: { X-CSRF-Token: window.csrfToken } });如果是Axiosaxios.defaults.headers.common[X-CSRF-Token] window.csrfToken;4.2 前后端分离与单页应用的无感知接入现在企业里前后端分离占了绝大多数前端Vue/React后端只提供REST API。这种情况下Token没法塞进表单隐藏域需要单独设计下发链路。最常见也是最稳妥的方式用户登录成功后后端在响应体里返回CSRF Token前端存入内存或状态管理器中随后所有写请求通过请求拦截器自动携带该Token。登录接口返回结构类似{ code: 0, data: { userId: 1001, csrfToken: abc123..., expiresIn: 1800 } }前端拦截器统一处理service.interceptors.request.use(config { const token store.getState().csrfToken; if (token) { config.headers[X-CSRF-Token] token; } return config; });Token放在内存里而不是localStorage是一个安全决策。localStorage里的数据任何脚本都能读取一旦站点被注入恶意脚本Token就完全暴露。站在CSRF防御的视角上Token只要不落入攻击者的构造能力范围内就行内存变量足够。这个方案有一个天然薄弱点Token是登录时下发的如果用户登录态长期有效而Token从未轮换理论上Token泄露的时间窗口会被拉长。所以企业级实现里建议给Token设计两层机制一是随会话过期自然失效二是在后台管理端进行一次强制轮换。4.3 多标签页与Token过期导致的状态错乱问题真正在企业环境里被吐槽最多的其实是多标签页场景。用户在浏览器里开着三个后台标签页所有页面共用一个Token当某个标签页的提交导致Token轮换比如重新登录其他标签页的Token全部失效用户的另一个操作会直接403。这个体验问题在管理后台项目中非常典型。我验证过几种解法实际最有效的是Token不变、会话绑定的策略只要用户Session有效就不重复生成Token。也就是说generateAndStoreToken方法在Session中已有Token时直接返回旧Token而不是每次生成新的。这样做的基础是CSRF Token携带在Session中不轮换也不影响安全性因为Token本来就是私有不可猜的。只有当登录状态被重建session.invalidate后重新登录时才生成新Token。严格意义上CSRF Token不轮换的安全性如何评估Token的保密性和随机性是核心只要生成端用的是SecureRandom、32字节输出长时间不换也不存在可预测性。引入登录后轮换机制就已经能防范重放类攻击普通业务场景完全不需要高频轮换的复杂度。4.4 网关层与微服务架构的穿透问题微服务架构中前端请求先经过网关再转发到下游服务。如果只在网关层做了CSRF校验下游服务直接收的都是内部信任的请求等于把安全边界全部压在一个节点上。如果只在某个业务服务里做了校验其他兄弟服务又是敞开的攻击者绕过网关直连服务端口即可完成攻击。企业标准做法是网关层做全局CSRF过滤内部服务之间用服务鉴权机制如mTLS或内部Token相隔离不再重复做CSRF校验。这样的好处是避免同一请求经过多个服务时Token被反复验证、前端接入又要考虑多套规则。注意网关校验要注意转发时不要把Token串到URL参数里。我见过有的团队为了方便排错把Token拼接在转发路径的Query String上结果Token出现在网关日志、业务服务日志中日志一旦被拖库就等于把Token白送了。正确的做法是校验完成后从Request Header中剥离再往内网转发只在内存中保留校验标记。5. 常见问题与防绕过实践企业落地时最容易被卡住的5个环节5.1 白名单配置失手很多团队在对接第三方系统时遇到回调接口的来源域名不在白名单里就把Origin校验整体关掉了。这等于拆了辅助校验的防护墙。正确的做法是精准放行在CsrfOriginChecker的白名单里加上第三方回调域名而不是全局关闭来源校验。实在遇到极端特殊场景也必须用单独的策略配置类把豁免范围缩到最小同时保留Token校验。5.2 文件上传接口的校验遗漏文件上传接口是CSRF防护的高发漏网点。很多过滤器的URL匹配规则只覆盖了业务管理类接口像/api/upload这种文件上传路径不在拦截规则里攻击者可以诱导受害者上传文件甚至上传的是攻击脚本从而向服务器投递恶意内容。除了把它加入拦截规则还需要额外注意文件上传接口往往有独立的上传大小限制和格式校验这些也需要同时加固防止CSRF和畸形文件攻击形成组合漏洞。5.3 静态资源与预检请求OPTIONS预检请求在SAFE_METHODS中已经放行但跨域场景下前端发起自定义Header请求时会先发一次OPTIONS预检如果后端没有正确响应Access-Control-Allow-Headers中包含X-CSRF-Token浏览器会直接拦截后续请求。这个问题表现起来和CSRF本身无关但排查起来让人头疼明明Token正确请求就是发不出去。解决方案是在网关或Web层的CORS配置中显式允许该Headerregistration.addMapping(/**) .allowedOrigins(https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(X-CSRF-Token, Content-Type) .allowCredentials(true);5.4 单点登录与OAuth回调的豁免误区企业应用接入了SSO后认证服务器回调业务系统时请求携带的是认证服务器的身份凭证不经过用户浏览器。这种请求做CSRF校验没有意义应该豁免。但豁免必须是精准的只豁免认证回调的特定URL并且该URL不能接受普通业务参数。我见过一个系统豁免规则写得过于宽泛理论上/sso/login/这个路径给了完全放行但代码里同一个Controller还挂了一个获取用户信息的接口结果用户信息接口被CSRF打穿。这种低级失误在真实项目里比想象中常见核心原因就是豁免规则没有细化到URLMethodHandler维度。5.5 日志与监控CSRF防御体系上线后最容易被人忽略的是没有任何观测手段。攻击者尝试伪造请求被403拦截听不到任何警报也不会在监控大盘上留下痕迹。企业级系统应该在过滤器中打点记录拦截到的请求来源、目标接口、携带Token是否为空等信息并把这些日志接入告警系统。正常用户不会频繁触发403如果同一IP或同一Session频繁触发CSRF校验失败很大程度上说明有人在手工构造攻击尝试这种异常事件应该被及时感知。if (!valid) { log.warn(CSRF validation failed. ip{}, uri{}, method{}, userAgent{}, request.getRemoteAddr(), request.getRequestURI(), request.getMethod(), request.getHeader(User-Agent)); // 发送告警事件到监控系统 alertService.sendSecurityAlert(CSRF_VALIDATION_FAILED, context); }6. 整体防御体系测试验证与效果复盘从零构建完这套CSRF防御体系后不能直接上线就算完事必须经过系统化验证。我先说测试方法再摆数据。第一类是功能验证。写接口自动化用例对每个写操作分别测试三种情况不带Token、带错误Token、带正确Token预期结果分别是403、403、200。这覆盖了过滤器基本的拦与放逻辑。第二类是场景验证。打开两个浏览器或不带Cookie的隐身模式用正常账号登录后台然后尝试在未登录的浏览器中直接构造POST请求Token只能是随机猜的观察是否被拦截。这个用例模拟的是攻击者视角。第三类是自动化安全扫描。现在很多商业级/开源安全扫描工具都能识别CSRF漏洞扫描报告可以精准指出哪些接口校验缺失。建议在CI流水线里加入安全任务每次发布前自动扫一遍强制高危漏洞不能发布。我在某个真实管理后台项目里跑过这套方案的数据说几个有代表性的结果上线前漏洞扫描共标记47处CSRF风险点按上述方案改造后复扫高危和中危全部清零之前登录接口经常被刷、后台数据被恶意修改的偶发问题在防护上线后没有再出现过。从整体效果上讲这套体系的最大意义不是某一个防线的强度而是把只见树木不见森林的CSRF防御变成了一条可观测、可控制的完整链路。7. 防御体系上线后的运行维护与迭代方向系统上线三个月后回头看有几个运维层面的体会值得讲透。第一Token校验失败的错误响应要设计得对前端友好。我最初直接返回403和一个JSON错误体结果前端接错处理逻辑用户看到的不是页面过期请刷新而是页面错误。后来统一了错误码和提示文案前端按错误码区分处理体验问题才算解决。第二CSRF防御不是一次性的它和系统的会话策略、登录策略、CORS策略是耦合的。改登录逻辑时可能动了Session的生命周期Token机制跟着要验证改CORS配置时如果跨域行为变化Token的下发链路也可能受影响。建议把这几个关联模块的测试用例放在同一个回归集里任何一处改动整体跑一遍。第三如果后续要引入Spring Security的官方CSRF模块来代替自研过滤器需要注意Spring Security 6之后把默认的CookieCsrfTokenRepository即XorCsrfTokenRequestAttributeHandler改成了CsrfTokenRequestAttributeHandler的默认行为平滑切换时要回归一遍多标签页、AJAX、文件上传几个场景。最后聊一下这个体系的后续扩展方向。当前实现是基于Session的如果未来全面切换为无状态JWT认证CSRF Token的存储方案需要适配Redis无状态模式。还要考虑的是应用如果接入第三方生态比如小程序、企业微信内嵌页来源校验可能要从Header域名校验切换为校验业务侧的会话标识。但无论怎么演进生成不可预测的Token、绑定用户会话、多通道校验、全链路可观测这个核心骨架不会变。企业级安全的本质不是一次性上线某个漏洞修复而是让修复方案跟随业务架构的变化持续演进。