说说什么是 CSRF 攻击?如何防止?
前阵子帮老项目做安全加固,测试小哥扔过来一个截图 —— 用个假网页居然调通了咱系统的 “修改用户角色” 接口。查了半天发现,居然是早年没做 CSRF 防护留下的坑。做 Java 开发八年,从 SSH 写传统管理系统,到 Spring Cloud 搞微服务,跟 CSRF 斗智斗勇的次数不少,今天就从实战角度,把这事儿聊透 —— 不整虚的理论,只说项目里能用的干货。
一、先破误区:CSRF 不是 “偷数据”,是 “冒你干活”
很多人一听到 “网络攻击” 就觉得是 “偷密码、扒数据”,但 CSRF(跨站请求伪造)不一样 —— 它是拿着你的 “已登录身份”,冒充你发恶意请求。打个比方:你刚登录电商后台,还没退出,点开个 “领优惠券” 的链接,结果后台悄悄执行了 “取消订单”—— 这就是 CSRF 干的。
咱拿 Java 后端最常见的 “退款接口” 举例,早年我写的接口是这样的:
- 请求方式:POST
- 路径:
/api/order/refund - 参数:
orderId=12345 - 校验逻辑:只查 Session 里有没有登录信息,有就执行退款。
结果测试小哥用这招破了防护:
- 做个静态网页,里面藏个自动提交的表单:
<form action=\"https://咱的电商域名/api/order/refund\" method=\"POST\">
<input type=\"hidden\" name=\"orderId\" value=\"67890\">
</form>
<script>document.forms[0].submit();</script>
- 用管理员账号登录咱的系统,再打开这个静态网页;
- 浏览器一看请求是发往 “咱的电商域名”,自动带上了 Session 对应的 JSESSIONID Cookie;
- 后端校验 Session 有效,直接执行了退款 —— 全程管理员没点任何 “确认” 按钮。
这里必须划两个重点,很多同行都踩过:
- POST 请求一样会被 CSRF 攻击!别以为只有 GET 危险,隐藏表单、JS 自动提交都能发起 POST 请求;
- CSRF 能成,全靠俩 “漏洞”:浏览器会自动给目标域名带 Cookie,以及后端只认 “登录状态”,不认 “请求是不是用户主动发的”。
二、Java 项目里,这 4 个场景最容易中招
八年下来,从单体项目到微服务,我总结了 CSRF 的高频 “踩坑点”,看看你项目里有没有:
- 传统 JSP/Thymeleaf 表单没防护:早年做管理系统,表单直接写
form action=\"/https://images.downcodes.comxxx\",没加任何验证。攻击者仿写个一模一样的表单,骗用户点一下,请求就发出去了; - 前后端分离忽略 AJAX 请求:有些同学觉得用了 Vue/React 就安全了,后端没做防护。攻击者用 JS 发跨域 AJAX(靠 CORS 配置漏洞绕开限制),一样能带上 Cookie 发起请求;
- 单独依赖 Referer 校验:有些项目靠 “校验请求来源(Referer)” 防 CSRF,但 Referer 能被篡改(比如用浏览器插件、Flash)。我之前碰到过生产环境被这么绕过的,差点出大事;
- 只防 “高危接口”,漏了 “弱敏感接口” :只在 “删数据、转钱” 接口加防护,却忘了 “修改收货地址”“绑定手机号” 这类接口。攻击者一样能通过这些接口篡改用户信息,造成损失。
三、Java 实战:4 个防护方案,覆盖所有场景
结合项目经验,这 4 个方案最实用,从单体到分布式、前后端分离都能用,咱一个个说:
方案 1:首选 ——CSRF Token 验证(通用性最强)
核心思路:给每个合法请求加个 “随机验证码”,后端验完再干活。Spring 项目里这么落地:
步骤 1:生成 Token
用户登录成功后,用UUID生成 Token,单体项目存 Session,分布式项目存 Redis(Key 用用户 ID 或 SessionID):
// 登录成功后调用
public void generateCsrfToken(HttpServletRequest request) {
String csrfToken = UUID.randomUUID().toString();
// 单体项目存Session
request.getSession().setAttribute(\"csrfToken\", csrfToken);
// 分布式项目存Redis(示例)
// redisTemplate.opsForValue().set(\"csrf_token_\" + request.getSession().getId(), csrfToken, 30, TimeUnit.MINUTES);
}
步骤 2:前端携带 Token
- 传统表单:加隐藏域,用 Thymeleaf 取值:
<form action=\"/api/order/refund\" method=\"POST\">
<input type=\"hidden\" name=\"_csrf\" th:value=\"${session.csrfToken}\">
<input type=\"text\" name=\"orderId\" value=\"12345\">
<button type=\"submit\">申请退款
- 前后端分离:前端先调用
/api/getCsrfToken拿 Token,存 localStorage,再在 AJAX Header 里带:
// 拿Token
axios.get(\"/api/getCsrfToken\").then(res => {
localStorage.setItem(\"csrfToken\", res.data.token);
});
// 发请求
axios.post(\"/api/order/refund\", { orderId: 12345 }, {
headers: { \"X-CSRF-Token\": localStorage.getItem(\"csrfToken\") }
});
步骤 3:后端拦截校验
写个拦截器,拦截所有非 GET 请求,校验 Token:
@Component
public class CsrfInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 排除GET/OPTIONS请求(OPTIONS是跨域预检请求)
String method = request.getMethod();
if (\"GET\".equalsIgnoreCase(method) || \"OPTIONS\".equalsIgnoreCase(method)) {
return true;
}
// 1. 拿存储的Token(单体从Session,分布式从Redis)
String storedToken = (String) request.getSession().getAttribute(\"csrfToken\");
// 分布式取法:String storedToken = redisTemplate.opsForValue().get(\"csrf_token_\" + request.getSession().getId());
if (storedToken == null) {
response.setStatus(403);
response.getWriter().write(\"CSRF Token不存在,请重新登录\");
return false;
}
// 2. 拿请求里的Token(表单取参数,AJAX取Header)
String requestToken = request.getParameter(\"_csrf\");
if (requestToken == null) {
requestToken = request.getHeader(\"X-CSRF-Token\");
}
if (requestToken == null) {
response.setStatus(403);
response.getWriter().write(\"请携带CSRF Token\");
return false;
}
// 3. 校验Token
if (!storedToken.equals(requestToken)) {
response.setStatus(403);
response.getWriter().write(\"CSRF Token校验失败\");
return false;
}
// 可选:验证成功后刷新Token,防止重复使用
request.getSession().setAttribute(\"csrfToken\", UUID.randomUUID().toString());
return true;
}
}
步骤 4:注册拦截器
在 Spring Boot 里配置拦截规则,只拦截需要防护的接口:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private CsrfInterceptor csrfInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(csrfInterceptor)
.addPathPatterns(\"/api/**\") // 要防护的接口
.excludePathPatterns(\"/api/login\", \"/api/getCsrfToken\"); // 排除登录、拿Token的接口
}
}
方案 2:轻量方案 ——SameSite Cookie(适合现代浏览器)
近几年最推荐的 “懒人方案”,利用 Cookie 的 SameSite 属性限制携带规则,不用写太多代码:
原理
设置SameSite=Strict或SameSite=Lax后,浏览器会拒绝在 “跨站请求” 中携带 Cookie(比如从攻击者的网页发请求,就不会带你的系统 Cookie)。
Spring Boot 配置
@Configuration
public class CookieConfig {
@Bean
public ServletContextInitializer servletContextInitializer() {
return servletContext -> {
SessionCookieConfig sessionCookieConfig = servletContext.getSessionCookieConfig();
// Lax比Strict更灵活:同站请求、GET的跨站请求(比如从百度跳过来的GET)能携带Cookie
sessionCookieConfig.setSameSite(\"Lax\");
// 配合HttpOnly,防止JS窃取Cookie(双重保障)
sessionCookieConfig.setHttpOnly(true);
// 生产环境一定要开Secure,只在HTTPS下传输Cookie
sessionCookieConfig.setSecure(true);
};
}
}
注意
兼容 Chrome 51+、Firefox 60+,如果要兼容老浏览器,得和 Token 方案配合用。
方案 3:前后端分离专属 —— 双重 Cookie 验证
适合无状态服务(不用 Session),核心思路:Token 存在 Cookie 里,但前端要把 Cookie 里的 Token 放到 Header 里,后端校验 “Cookie Token” 和 “Header Token” 一致:
步骤 1:登录后存 Token 到 Cookie
public void setCsrfCookie(HttpServletResponse response) {
String csrfToken = UUID.randomUUID().toString();
Cookie cookie = new Cookie(\"csrfToken\", csrfToken);
cookie.setPath(\"/\");
cookie.setHttpOnly(false); // 允许前端JS读取
cookie.setSecure(true); // HTTPS下传输
cookie.setMaxAge(30 * 60); // 30分钟有效期
response.addCookie(cookie);
}
步骤 2:前端读取 Cookie,带在 Header 里
// 读取Cookie的工具函数
function getCookie(name) {
let arr = document.cookie.split(\"; \");
for (let i = 0; i < arr.length; i++) {
let [key, value] = arr[i].split(\"=\");
if (key === name) return value;
}
return \"\";
}
// 发请求
axios.post(\"/api/order/refund\", { orderId: 12345 }, {
headers: { \"X-CSRF-Token\": getCookie(\"csrfToken\") }
});
步骤 3:后端校验
拦截器里加一段逻辑:
// 从Cookie拿Token
String cookieToken = null;
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (\"csrfToken\".equals(cookie.getName())) {
cookieToken = cookie.getValue();
break;
}
}
}
// 从Header拿Token
String headerToken = request.getHeader(\"X-CSRF-Token\");
// 校验一致
if (cookieToken == null || headerToken == null || !cookieToken.equals(headerToken)) {
response.setStatus(403);
response.getWriter().write(\"CSRF校验失败\");
return false;
}
方案 4:辅助防护 ——Referer/Origin 校验
不能单独用(Referer 可篡改),但能增加攻击难度,作为补充:
// 校验Referer/Origin
String referer = request.getHeader(\"Referer\");
String origin = request.getHeader(\"Origin\");
String allowedDomain = \"https://你的域名\";
// Origin优先级比Referer高,优先校验Origin
if (origin != null) {
if (!allowedDomain.equals(origin)) {
response.setStatus(403);
return false;
}
} else if (referer != null) {
if (!referer.startsWith(allowedDomain)) {
response.setStatus(403);
return false;
}
}
// 注意:如果是直接在浏览器输入地址访问(没有Referer/Origin),要特殊处理
四、八年踩坑总结:这 3 个错千万别犯
- 把 CSRF Token 存在 Cookie 里:等于给攻击者送 “弹药”—— 因为 Cookie 会自动携带,攻击者不用费劲就能拿到 Token,等于没防;
- 分布式项目里 Token 存 Session:多节点部署时,Session 不共享,A 节点生成的 Token,B 节点拿不到,会导致正常请求也被拦截;
- 只防 POST 不防 GET:虽然 GET 一般用于查询,但有些项目用 GET 做 “注销账号”“取消订单”(比如
/api/order/cancel?orderId=123),攻击者用就能发起请求,一样要防。
最后说句实在的:CSRF 防护不是 “加个 Token 就完事”,得结合项目场景选方案 —— 单体项目用 Token+Session,前后端分离用双重 Cookie,现代浏览器加 SameSite Cookie,再配合 Referer 校验做补充。只有形成 “组合拳”,才能真正防住。



还没有评论呢,快来抢沙发~