火柴人武林大会
156.74M · 2026-02-04
前阵子帮老项目做安全加固,测试小哥扔过来一个截图 —— 用个假网页居然调通了咱系统的 “修改用户角色” 接口。查了半天发现,居然是早年没做 CSRF 防护留下的坑。做 Java 开发八年,从 SSH 写传统管理系统,到 Spring Cloud 搞微服务,跟 CSRF 斗智斗勇的次数不少,今天就从实战角度,把这事儿聊透 —— 不整虚的理论,只说项目里能用的干货。
很多人一听到 “网络攻击” 就觉得是 “偷密码、扒数据”,但 CSRF(跨站请求伪造)不一样 —— 它是拿着你的 “已登录身份”,冒充你发恶意请求。打个比方:你刚登录电商后台,还没退出,点开个 “领优惠券” 的链接,结果后台悄悄执行了 “取消订单”—— 这就是 CSRF 干的。
咱拿 Java 后端最常见的 “退款接口” 举例,早年我写的接口是这样的:
/api/order/refundorderId=12345结果测试小哥用这招破了防护:
<form action="https://咱的电商域名/api/order/refund" method="POST">
<input type="hidden" name="orderId" value="67890"> <!-- 他想退的测试单 -->
</form>
<script>document.forms[0].submit();</script>
这里必须划两个重点,很多同行都踩过:
八年下来,从单体项目到微服务,我总结了 CSRF 的高频 “踩坑点”,看看你项目里有没有:
form action="/xxx",没加任何验证。攻击者仿写个一模一样的表单,骗用户点一下,请求就发出去了;结合项目经验,这 4 个方案最实用,从单体到分布式、前后端分离都能用,咱一个个说:
核心思路:给每个合法请求加个 “随机验证码”,后端验完再干活。Spring 项目里这么落地:
用户登录成功后,用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);
}
<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">申请退款</button>
</form>
/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") }
});
写个拦截器,拦截所有非 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;
}
}
在 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的接口
}
}
近几年最推荐的 “懒人方案”,利用 Cookie 的 SameSite 属性限制携带规则,不用写太多代码:
设置SameSite=Strict或SameSite=Lax后,浏览器会拒绝在 “跨站请求” 中携带 Cookie(比如从攻击者的网页发请求,就不会带你的系统 Cookie)。
@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 方案配合用。
适合无状态服务(不用 Session),核心思路:Token 存在 Cookie 里,但前端要把 Cookie 里的 Token 放到 Header 里,后端校验 “Cookie Token” 和 “Header Token” 一致:
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);
}
// 读取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") }
});
拦截器里加一段逻辑:
// 从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;
}
不能单独用(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),要特殊处理
/api/order/cancel?orderId=123),攻击者用<img src="xxx">就能发起请求,一样要防。最后说句实在的:CSRF 防护不是 “加个 Token 就完事”,得结合项目场景选方案 —— 单体项目用 Token+Session,前后端分离用双重 Cookie,现代浏览器加 SameSite Cookie,再配合 Referer 校验做补充。只有形成 “组合拳”,才能真正防住。