目录
- 跨域
- JSONP
- CORS
- Spring Security启用CORS
- CSRF
- CSRF的攻击过程
- CSRF的防御手段
- 使用Spring Security防御CSRF攻击
跨域
在之前的文章[Spring Boot或Spring MVC前后端分离的项目跨域问题的解决方案]已经介绍过跨域以及跨域的解决方案。
在学习Spring Security的时候发现,Security框架也通过HttpSecurity进行链式配置解决跨域问题,是通过CORS进行解决的,随意还是会重点讲解相关CORS。
JSONP
JSONP(JSON With Padding)是一种非官方的解决方案。由于浏览器允许一些带src属性的标签跨 域,例如,iframe、script、img等,所以JSONP利用script标签可以实现跨域。
虽然JSONP 的原理很简单,几乎兼容所有浏览器,实现起来也并不困难,但只支持 GET 请求跨域, 局限性较大。对于部分不需要考虑兼容老旧浏览器的系统来说,CORS 的方案显得更为优雅、灵活。
这里还是不做多讲解。
CORS
CORS(Cross-Origin Resource Sharing)的规范中有一组新增的HTTP首部字段,允许服务器声明其 提供的资源允许哪些站点跨域使用。通常情况下,跨域请求即便在不被支持的情况下,服务器也会接 收并进行处理,在CORS的规范中则避免了这个问题。浏览器首先会发起一个请求方法为OPTIONS 的 预检请求,用于确认服务器是否允许跨域,只有在得到许可后才会发出实际请求。此外,预检请求还允许服务器通知浏览器跨域携带身份凭证(如cookie)。
CORS新增的HTTP首部字段由服务器控制,下面我们来看看常用的几个首部字段:
Access-Control-Allow-Origin
允许取值为<origin>或。<origin>指被允许的站点,使用URL首部匹配原则。匹配所有站点,表示允许来自所有域的请求。但并非所有情况都简单设置即可,如果需要浏览器在发起请求时携带凭证信息,则不允许设置为*。如果设置了具体的站点信息,则响应头中的 Vary字段还需要携带Origin属性, 因为服务器对不同的域会返回不同的内容:
Access-Control-Allow-Origin:https://cuizb.top
Vary: Accept-Encoding, Origin
- Access-Control-Allow-Methods字段仅在预检请求的响应中指定有效,用于表明服务器允许跨域的 HTTP方法,多个方法之间用逗号隔开。
- Access-Control-Allow-Headers 字段仅在预检请求的响应中指定有效,用于表明服务器允许携带的首部字段。多个首部字段之间用逗号隔开。
- Access-Control-Max-Age 字段用于指明本次预检请求的有效期,单位为秒。在有效期内,预检请 求不需要再次发起。
- Access-Control-Allow-Credentials字段取值为true时,浏览器会在接下来的真实请求中携带用户凭证信息(cookie等),服务器也可以使用Set-Cookie向用户浏览器写入新的cookie。注意,使用Access-Control-Allow-Credentials时,Access-Control-Allow-Origin不应该设置为。总体来说,CORS 是一种更安全的官方跨域解决方案,它依赖于浏览器和后端,即当需要用CORS来解决跨域问题时,只需要后端做出支持即可。前端在使用这些域时,基本等同于访问同源站点资源。*注意,CORS不支持IE8以下版本的浏览器。
在使用CORS时,通常有以下三种访问控制场景。
- 简单请求
在CORS中,并非所有的跨域访问都会触发预检请求。例如,不携带自定义请求头信息的GET 请
求、HEAD 请求,以及 Content-Type 为application/x-www-form-urlencoded、multipart/form-data或text/plain的POST请求,这类请求被称为简单请求。
浏览器在发起请求时,会在请求头中自动添加一个 Origin 属性,值为当前页面的 URL 首部。当服务器返回响应时,若存在跨域访问控制属性,则浏览器会通过这些属性判断本次请求是否被允许,如果允许,则跨域成功(正常接收数据)。
这种跨域请求非常简单,只需后端在返回的响应头中添加 Access-Control-Allow-Origin 字段并填入 允许跨域访问的站点即可。
- 预检请求
预检请求不同于简单请求,它会发送一个 OPTIONS
请求到目标站点,以查明该请求是否安全,防止请求对目标站点的数据造成破坏。若是请求以 GET、HEAD、POST 以外的方法发起;或者使用
POST方法,但请求数据为application/x-www-form-urlencoded、multipart/form-data和text/plain以外的数据类型;再或者,使用了自定义请求头,则都会被当成预检请求类型处理。
- 带凭证的请求
带凭证的请求,顾名思义,就是携带了用户cookie等信息的请求。
Spring Security启用CORS
Spring Security对CORS提供了非常好的支持,只需在配置器中启用CORS支持,并编写一 个CORS配置源即可。
在之前的文章中也有提到启用CORS。相关配置类如下
代码语言:javascript复制import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
/**
* @author: Java技术债务
* @Date: 2021/6/5 18:45
* Describe: SpringSecurity配置
*/
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Slf4j
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/test/**").hasRole("ADMIN")
.antMatchers("/user/test/**").hasRole("USER")
.antMatchers("/web/test/**").permitAll()
.anyRequest().authenticated()
.and()
//允许跨域
.cors()
.and()
.formLogin();
}
}
核心实现并不复杂,DefaultCorsProcessor
中的handleInternal
方法是处理CORS的核心,流程非常清晰。
/**
* Handle the given request.
*/
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response,
CorsConfiguration config, boolean preFlightRequest) throws IOException {
//获取request中请求域
String requestOrigin = request.getHeaders().getOrigin();
//校验request请求域是否被允许
String allowOrigin = checkOrigin(config, requestOrigin);
HttpHeaders responseHeaders = response.getHeaders();
if (allowOrigin == null) {
logger.debug("Reject: '" requestOrigin "' origin is not allowed");
rejectRequest(response);
return false;
}
//校验被允许的方法
HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
if (allowMethods == null) {
logger.debug("Reject: HTTP '" requestMethod "' is not allowed");
rejectRequest(response);
return false;
}
List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
//校验被允许的头
List<String> allowHeaders = checkHeaders(config, requestHeaders);
if (preFlightRequest && allowHeaders == null) {
logger.debug("Reject: headers '" requestHeaders "' are not allowed");
rejectRequest(response);
return false;
}
responseHeaders.setAccessControlAllowOrigin(allowOrigin);
if (preFlightRequest) {
responseHeaders.setAccessControlAllowMethods(allowMethods);
}
if (preFlightRequest && !allowHeaders.isEmpty()) {
responseHeaders.setAccessControlAllowHeaders(allowHeaders);
}
if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
}
if (Boolean.TRUE.equals(config.getAllowCredentials())) {
responseHeaders.setAccessControlAllowCredentials(true);
}
if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
response.flush();
return true;
}
CSRF
CSRF的全称是(Cross Site Request Forgery),可译为跨域请求伪造,是一种利用用户带登录 态的cookie迚行安全操作的攻击方式。CSRF实际上并不难防,但常常被系统开发者忽略,从而埋下巨 大的安全隐患。
CSRF的攻击过程
假如有一个博客网站,为了激励用户写出高质量的博文,设定了一个文章被点赞就能奖励现金的机制,于是有了一个可用于点赞的API,只需传入文章id即可:
https://www.cuizb.top/article/like?id=xxx
在安全策略上,限定必须是本站有效登录用户才可以点赞,且每个用户对每篇文章仅可点赞一次,防止无限刷赞的情况发生。
这套机制推行起来似乎没什么问题,直我们发现有个用户的文章总是有非常多的点赞数,哪怕只 是发表了一条个人状态也有非常多的点赞数,而这些点赞记录也确实都是本站的真实用户发起的。察觉到异常之后,开始对这个用户的所有行为进行排查,发现该用户几乎每篇文章都带有一张很特别的图片,这些图片的URL无一例外地指向了对应文章的点赞API。由于图片是由浏览器自动加载的,所以每个查看过该文章的人都会不知不觉为其点赞。很显然,该用户利用了系统的CSRF漏洞实施刷赞, 这是网站开发人员始料未及的。
有人可能认为这仅仅是因为点赞API设计不理想导致的,应当使用POST请求,这样就能避免上面的场景。然而,当使用POST请求时,确实避免了如img、scrip t、iframe等标签自动发起GET请求的问 题,但这并不能杜绝CSRF攻击的发生。一些恶意网站会通过表单的形式构造攻击请求:
代码语言:javascript复制<form action="http://127.0.0.1:8080/article/like" method="post">
<input type="hiddn" name="id" value="xxx" />
<input type="submit" value="诱色可餐"/>
</form>
假如登录过某银行站点而没有注销,其间被诱导访问了带有类似攻击的页面,那么在该页面一旦单击按钮,很可能会导致在该银行的账户资金被直接转走。甚至根本不需要单击按钮,而是直接用JavaScript代码自动化该过程。
CSRF 利用了系统对登录期用户的信任,使得用户执行了某些并非意愿的操作从而造成损失。如何真正地防范CSRF攻击,对每个有安全需求的系统而言都尤为重要。
CSRF的防御手段
一些工具可以检测系统是否存在 CSRF 漏洞,例如,CSRFTester,有兴趣的读者可以自行了解。在任何情况下,都应当尽可能地避免以GET方式提供涉及数据修改的API。在此基础上,防御 CSRF攻击的方式主要有以下两种。
- HTTP Referer
HTTP Referer是由浏览器添加的一个请求头字段,用于标识请求来源,通常用在一些统计相关的场景,浏览器端无法轻易篡改该值。
回到前面构造POST请求实行CSRF攻击的场景,其必要条件就是诱使用户跳转到第三方页面,在第三方页面构造发起的POST请求中,HTTP Referer字段不是银行的URL(少部分老版本的IE浏览器可
以调用API进行伪造,但最后的执行逻辑是放在用户浏览器上的,只要用户的浏览器版本较新,便可
以避免这个问题),当校验到请求来自其他站点时,可以认为是CSRF攻击,从而拒绝该服务。
当然,这种方式简单便捷,但并非完全可靠。除前面提到的部分浏览器可以篡改 HTTP Referer外,如果用户在浏览器中设置了不被跟踪,那么HTTP Referer字段就不会自动添加,当合法用户访问
时,系统会认为是CSRF攻击,从而拒绝访问。
- CsrfToken认证
CSRF是利用用户的登录态进行攻击的,而用户的登录态记录在cookie中。其实攻击者并不知道用户的cookie存放了哪些数据,于是想方设法让用户自身发起请求,这样浏览器便会自行将cookie传送到
服务器完成身份校验。
CsrfToken 的防范思路是,添加一些并不存放于 cookie 的验证值,并在每个请求中都进行校验,
便可以阻止CSRF攻击。
具体做法是在用户登录时,由系统发放一个CsrfToken值,用户携带该CsrfToken值与用户名、密码
等参数完成登录。系统记录该会话的 CsrfToken 值,之后在用户的任何请求中,都必须带上该
CsrfToken值,并由系统进行校验。
这种方法需要与前端配合,包括存储CsrfToken值,以及在任何请求中(包括表单和Ajax)携带CsrfToken值。安全性相较于HTTP Referer提高很多,但也存在一定的弊端。例如,在现有的系统中进行改造时,前端的工作量会非常大,几乎要对所有请求进行处理。如果都是XMLHttpRequest,则可以统一添加CsrfToken值;但如果存在大量的表单和a标签,就会变得非常烦琐。因此建议在系统开发之初考虑如何防御CSRF攻击。
使用Spring Security防御CSRF攻击
CSRF攻击完全是基于浏览器进行的,如果我们的系统前端并非在浏览器中运作,就应当关闭CSRF。Spring Security通过注册一个CsrfFilter来专门处理CSRF攻击。
在Spring Security中, CsrfToken是一个用于描述Token值,以及验证时应当获取哪个请求参数或请
求头字段的接口。
CsrfTokenRepository则定义了如何生成、保存以及加载CsrfToken。
在默认情况下,Spring Security加载的是一个HttpSessionCsrfTokenRepository。
代码语言:javascript复制/*
* Copyright 2002-2013 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.web.csrf;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.util.Assert;
/**
* A {@link CsrfTokenRepository} that stores the {@link CsrfToken} in the
* {@link HttpSession}.
*
* @author Rob Winch
* @since 3.2
*/
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class
.getName().concat(".CSRF_TOKEN");
private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
private String headerName = DEFAULT_CSRF_HEADER_NAME;
private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;
/*
* (non-Javadoc)
*
* @see org.springframework.security.web.csrf.CsrfTokenRepository#saveToken(org.
* springframework .security.web.csrf.CsrfToken,
* javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)
*/
public void saveToken(CsrfToken token, HttpServletRequest request,
HttpServletResponse response) {
if (token == null) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(this.sessionAttributeName);
}
}
else {
HttpSession session = request.getSession();
session.setAttribute(this.sessionAttributeName, token);
}
}
/*
* (non-Javadoc)
*
* @see
* org.springframework.security.web.csrf.CsrfTokenRepository#loadToken(javax.servlet
* .http.HttpServletRequest)
*/
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return (CsrfToken) session.getAttribute(this.sessionAttributeName);
}
/*
* (non-Javadoc)
*
* @see org.springframework.security.web.csrf.CsrfTokenRepository#generateToken(javax.
* servlet .http.HttpServletRequest)
*/
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName,
createNewToken());
}
/**
* Sets the {@link HttpServletRequest} parameter name that the {@link CsrfToken} is
* expected to appear on
* @param parameterName the new parameter name to use
*/
public void setParameterName(String parameterName) {
Assert.hasLength(parameterName, "parameterName cannot be null or empty");
this.parameterName = parameterName;
}
/**
* Sets the header name that the {@link CsrfToken} is expected to appear on and the
* header that the response will contain the {@link CsrfToken}.
*
* @param headerName the new header name to use
*/
public void setHeaderName(String headerName) {
Assert.hasLength(headerName, "headerName cannot be null or empty");
this.headerName = headerName;
}
/**
* Sets the {@link HttpSession} attribute name that the {@link CsrfToken} is stored in
* @param sessionAttributeName the new attribute name to use
*/
public void setSessionAttributeName(String sessionAttributeName) {
Assert.hasLength(sessionAttributeName,
"sessionAttributename cannot be null or empty");
this.sessionAttributeName = sessionAttributeName;
}
private String createNewToken() {
return UUID.randomUUID().toString();
}
}
HttpSessionCsrfTokenRepository 将 CsrfToken 值存储在 HttpSession 中,并指定前端把CsrfToken 值放在名为“_csrf”的请求参数或名为“X-CSRF-TOKEN”的请求头字段里(可以调用相应的设置方法来 重新设定)。校验时,通过对比HttpSession内存储的CsrfToken值与前端携带的CsrfToken值是否一致, 便能断定本次请求是否为CSRF攻击。
Spring Security还提供了一种方式,即CookieCsrfTokenRepository。CookieCsrfTokenRepository 是一种更加灵活可行的方案,它将 CsrfToken 值存储在用户的cookie 内。首先,减少了服务器HttpSession存储的内存消耗;其次,当用cookie存储CsrfToken值时,前端可以用JavaScript读取(需要设置该cookie的httpOnly属性为false),而不需要服务器注入参数,在使用方式上更加灵活。