写在前面

前面我们对SpringSecurity中用户登录相关内容进行了深度学习,接下来开始学习SpringSecurity自带的防火墙,了解和使用防火墙对于提升系统的安全性有重要帮助。

杂谈

在学习SpringSecurity之前,我们对Shiro框架进行了学习,发现它非常轻量,没有这么复杂的功能和配置。同时随着对SpringSecurity框架的深度学习,我们发现它底层其实用的还是Servlet的那套东西?就前面所述的自动踢掉登录用户这一功能来说,开发者完全可以自定义一个Filter来实现请求拦截,而且逻辑和配置非常简单。

尽管是可以这么操作,但是笔者不建议大家这样操作,因为我们自定义Filter可能仅仅是为了让认证和授权等功能变得简单,但是却忽略了安全等问题,显然安全必须是首要考虑的。

其实各种各样的Web攻击每天都在发生,可能你感知不到,那是因为有系统在保护着,小到系统本身自带的攻击防御,大到公司的防火墙。如果你自定义了Filter,那么你就需要针对不同的攻击,书写对应的代码来防护,毫无疑问,这就要求开发者不仅对一些常见的Web攻击,如固定会话攻击,CSRF攻击等有较为详细的了解,而且还具备一定的安全防御知识,这势必会增加开发者的学习负担,因此使用SpringSecurity的好处就是开发者既不需要知道这些攻击,又不需要具备防御这些攻击的知识,只需按照既定的配置规则来进行相应的设置即可。

项目实例化

第一步,使用IDEA创建一个名为security-firewall的SpringBoot工程,并在其pom.xml依赖文件中添加如下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

第二步,在application.yml配置文件中新增用户信息:

1
2
3
4
5
spring:
security:
user:
name: envy
password: 1234

第三步,新建controller包,并在里面新建一个HelloController类,里面的代码如下:

1
2
3
4
5
6
7
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello(){
return "hello,world!";
}
}

第四步,启动项目进行测试。用户访问/hello接口就会跳转到/login页面,输入正确的用户名和密码后,点击登录即可完成登录,页面显示既定的hello,world!信息。

HttpFirewall

SpringSecurity提供了一个HttpFirewall接口用于配置请求防火墙信息,它可以自动处理一些非法请求。查看一下该接口的源码:

1
2
3
4
5
public interface HttpFirewall {
FirewalledRequest getFirewalledRequest(HttpServletRequest var1) throws RequestRejectedException;

HttpServletResponse getFirewalledResponse(HttpServletResponse var1);
}

可以看到这个接口里面包含了两个方法,用于获取通过防火墙的请求和相应信息。接着查看该接口的实现类:

可以看到它有两个实现类,一个是StrictHttpFirewall表示严格模式的防火墙设置,另一个则是DefaultHttpFirewall表示默认的防火墙设置。从实现类的名字中就能看出StrictHttpFirewall类比DefaultHttpFirewall类限制多一些,自然安全性更高一些。请注意,SpringSecurity中默认使用的实现类是StrictHttpFirewall

防御措施

接下来学习SpringSecurity中一些常用的防御措施,主要包括:(1)只允许执行白名单中的方法;(2)请求地址中不能包含分号;(3)请求地址必须是标准化的URL;(4)请求地址中不能包含不可打印的ASCII字符;(5)请求地址中不能出现双斜杠;(6)请求地址中不能出现%;(7)请地址中不能包含反斜杠\或者反斜杠编码后的字符,诸如%2F等;(8)请求地址中不能包含.编码后的%2e或者%2E

只允许执行白名单中的方法

对于HTTP请求来说,并不是所有的方法都能被执行,只允许白名单中的方法才可以执行。

查看一下StrictHttpFirewall类中如下一段源码,就能明白其中的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class StrictHttpFirewall implements HttpFirewall {
......
private Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();
private static Set<String> createDefaultAllowedHttpMethods() {
Set<String> result = new HashSet();
result.add(HttpMethod.DELETE.name());
result.add(HttpMethod.GET.name());
result.add(HttpMethod.HEAD.name());
result.add(HttpMethod.OPTIONS.name());
result.add(HttpMethod.PATCH.name());
result.add(HttpMethod.POST.name());
result.add(HttpMethod.PUT.name());
return result;
}
private void rejectForbiddenHttpMethod(HttpServletRequest request) {
if (this.allowedHttpMethods != ALLOW_ANY_HTTP_METHOD) {
if (!this.allowedHttpMethods.contains(request.getMethod())) {
throw new RequestRejectedException("The request was rejected because the HTTP method \"" + request.getMethod() + "\" was not included within the whitelist " + this.allowedHttpMethods);
}
}
}
......
}

上面首先定义了一个集合对象allowedHttpMethods ,里面存放的都是允许的HTTP方法,之后调用createDefaultAllowedHttpMethods()方法来返回上述allowedHttpMethods对象。之后里面还定义了一个rejectForbiddenHttpMethod()方法来判断当前方法是否允许访问。也就是说只有HTTP请求方法是DELETE、GET、HEAD、OPTIONS、PATCH、POST和PUT,即allowedHttpMethods对象中包含的HTTP请求方法才能发生成功,除此之外的其他方法都会抛出RequestRejectedException异常。

如果开发者想发送其他的HTTP请求方法,如TRACE,那么只需自己提供一个StrictHttpFirewall实例即可。

新建一个config包,并在该包内新建一个SecurityConfig类,其中的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
Set<String> allowedHttpMethods = new HashSet<>();
allowedHttpMethods.add("TRACE");
strictHttpFirewall.setAllowedHttpMethods(allowedHttpMethods);
return strictHttpFirewall;
}
}

通过上述配置,现在SpringSecurity只允许TRACE方法发送请求了。如果开发者想允许所有的HTTP请求方法都发送成功,那么只需将上述代码修改为如下所示:

1
2
3
4
5
6
7
8
9
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setUnsafeAllowAnyHttpMethod(true);
return strictHttpFirewall;
}
}

可以看到上面我们调用了setUnsafeAllowAnyHttpMethod()方法,该方法表示不对任何的HTTP请求方法进行校验。

请求地址中不能包含分号

为了后续演示需要,在HelloController类中新增一个/world接口,里面的代码如下所示:

1
2
3
4
@GetMapping("/world")
public String world(@RequestParam("sex") String sex){
return sex;
}

之后启动项目,访问/world?sex=male接口就会跳转到/login页面,输入正确的用户名和密码后,点击登录即可完成登录,页面显示male信息。

假设用户不小心将?号输成了;号,此时页面就会报错,因为SpringSecurity请求地址中是不能包含;号,如果包含了则报错:

那么什么时候请求地址中会包含;号呢?当开发者在使用Shiro框架的时候,如果禁用了Cookie,那么jessionid就会出现在地址栏中,类似于下面的样子:

1
http://localhost:8080/world;jsessionid=xsaove7963rbfviq1

很明显这种传递jsessionid的方式是非常不安全的,但是Shiro居然支持这种方式。不过在SpringSecurity中,这种传递参数的方式被禁用了。因此在SpringSecurity中,如果开发者希望地址栏中能出现;,那么就需要通过setAllowSemicolon(true)来进行允许,其中的Semicolon就是指分号。只需将之前httpFirewall方法中的代码修改为如下所示:

1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowSemicolon(true);
return strictHttpFirewall;
}

之后重新启动项目,访问/world;?sex=male接口就会跳转到/login页面,输入正确的用户名和密码后,点击登录即可完成登录,页面显示male信息:

需要注意的是,在URL地址栏中;号编码后就成了%3b或者%3B,因此三者只需要填一个即可。

2020年12月更新以下内容,需要引起格外注意。

Spring3.2引入了一个@MatrixVariable注解,该注解扩展了请求参数的传递格式,支持参数之间使用;号进行分隔,这个注解引入的真是不友好。SpringSecurity默认禁止了这种传参方式,因此如果开发者想在SpringSecurity中使用@MatrixVariable注解,那么就得在SpringSecurity中添加对应的配置。

为了后续演示需要,在HelloController类中新增一个/movie接口,里面的代码如下所示:

1
2
3
4
@GetMapping("/movie/{id}")
public String movie(@PathVariable Integer id, @MatrixVariable String name){
return String.format("id is:" +id+", name is:"+name);
}

光那样还不够,我们还需要在config包内新建一个MyWebMvcConfig类,在里面配置一下,使得;号不被自动移除:

1
2
3
4
5
6
7
8
9
10
@Configuration
public class MyWebMvcConfig extends WebMvcConfigurationSupport {

@Override
protected void configurePathMatch(PathMatchConfigurer configurer) {
UrlPathHelper urlPathHelper = new UrlPathHelper();
urlPathHelper.setRemoveSemicolonContent(false);
configurer.setUrlPathHelper(urlPathHelper);
}
}

请注意此时需要确保在SpringSecurity中已经配置了允许URL中存在;号,也就是配置了下面的代码:

1
2
3
4
5
6
7
8
9
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowSemicolon(true);
return strictHttpFirewall;
}
}

之后重启项目,访问/movie/100;name=book接口就会跳转到/login页面,输入正确的用户名和密码后,点击登录即可完成登录,页面显示如下信息:

从返回结果中就能看出此时@MatrixVariable注解就已经生效了。

请求地址必须是标准化的URL

什么样的URL才是标准化的URL?对于这个问题,我们需要从四个方面来进行判断。查看一下StrictHttpFirewall#isNormalized方法的源码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
private static boolean isNormalized(HttpServletRequest request) {
if (!isNormalized(request.getRequestURI())) {
return false;
} else if (!isNormalized(request.getContextPath())) {
return false;
} else if (!isNormalized(request.getServletPath())) {
return false;
} else {
return isNormalized(request.getPathInfo());
}
}

从上述代码中可以知道,它需要判断URL的四部分内容是否是标准化的,其中request.getRequestURI()方法用于获取请求host之外的字符;request.getContextPath()方法用于获取上下文路径,如果项目名映射为/,那么此处返回空;request.getServletPath()方法用于获取请求的Servlet路径;request.getPathInfo()方法用于获取除去contextPath和servletPath之外的其他内容,判断的依旧就是以上四个方法的返回值中都不能包含.//..//.三者中的任意一个。

以之前访问/movie/100;name=book接口为例,首先修改该接口的方法:

1
2
3
4
5
6
7
8
9
@GetMapping("/movie/{id}")
public String movie(@PathVariable String id, @MatrixVariable String name){
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
System.out.println("requestURI is:"+request.getRequestURI());
System.out.println("contextPath is:"+request.getContextPath());
System.out.println("servletPath is:"+request.getServletPath());
System.out.println("pathInfo is:"+request.getPathInfo());
return String.format("id is:" +id+", name is:"+name);
}

也就是说完整的URL为http://localhost:8080/movie/100;name=book,接下来依次输出上述四个方法的执行结果:

1
2
3
4
requestURI is:/movie/100;name=book
contextPath is:
servletPath is:/movie/100
pathInfo is:null

请求地址中不能包含不可打印的ASCII字符

还是那句话,通过阅读源码来进行学习。查看一下StrictHttpFirewall#containsOnlyPrintableAsciiCharacters1方法的源码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
int length = uri.length();

for(int i = 0; i < length; ++i) {
char c = uri.charAt(i);
if (c < ' ' || c > '~') {
return false;
}
}

return true;
}

可以看到当字符小于空格(ASCII值为\u0020),或者大于~(ASCII值为\u007e)时,就是不可打印的ASCII字符,开发者需要尽量进行规避。

请求地址中不能出现双斜杠

查看一下StrictHttpFirewall#setAllowUrlEncodedDoubleSlash方法的源码,如下所示:

1
2
3
4
5
6
7
public void setAllowUrlEncodedDoubleSlash(boolean allowUrlEncodedDoubleSlash) {
if (allowUrlEncodedDoubleSlash) {
this.urlBlacklistsRemoveAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
} else {
this.urlBlacklistsAddAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
}
}

可以看到,它通过判断allowUrlEncodedDoubleSlash属性(默认值为false)是否为真来调用不同的方法,并传入FORBIDDEN_DOUBLE_FORWARDSLASH参数。查看这个FORBIDDEN_DOUBLE_FORWARDSLASH属性的源码:

1
private static final List<String> FORBIDDEN_DOUBLE_FORWARDSLASH = Collections.unmodifiableList(Arrays.asList("//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F"));

可以看到这里默认的禁止双重转发列表为"//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F",也就是说如果请求地址中出现双斜杠(//使用URL地址编码后为%2F%2F,因此"//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F"应视为同一个)后,那么该请求地址也将被拒绝访问。

如果开发者想允许请求地址中出现//,那么只需通过setAllowUrlEncodedDoubleSlash(true)来进行允许,其中的Slash就是指斜杠。只需将之前httpFirewall方法中的代码修改为如下所示:

1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowUrlEncodedDoubleSlash(true);
return strictHttpFirewall;
}

请求地址中不能出现百分号

查看一下StrictHttpFirewall#setAllowUrlEncodedPercent方法的源码,如下所示:

1
2
3
4
5
6
7
8
9
10
public void setAllowUrlEncodedPercent(boolean allowUrlEncodedPercent) {
if (allowUrlEncodedPercent) {
this.encodedUrlBlacklist.remove("%25");
this.decodedUrlBlacklist.remove("%");
} else {
this.encodedUrlBlacklist.add("%25");
this.decodedUrlBlacklist.add("%");
}

}

可以看到这里默认禁止URL中出现百分号,开发者可以通过setAllowUrlEncodedPercent(true)来进行允许,其中的Percent就是指百分号。只需将之前httpFirewall方法中的代码修改为如下所示:

1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowUrlEncodedPercent(true);
return strictHttpFirewall;
}

请求地址中不能出现正反斜杠

当请求地址中包含斜杠编码后的字符%2F或者%2f,那么该请求将会被拒绝。

当请求地址中包含反斜杠\或者反斜杠\编码后的字符"\\", "%5c", "%5C",那么该请求也会被拒绝。

查看一下StrictHttpFirewall#setAllowBackSlash方法的源码,如下所示:

1
2
3
4
5
6
7
public void setAllowBackSlash(boolean allowBackSlash) {
if (allowBackSlash) {
this.urlBlacklistsRemoveAll(FORBIDDEN_BACKSLASH);
} else {
this.urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);
}
}

其中的BackSlash就是反斜杠,可以看到,它通过判断allowBackSlash属性(默认值为false)是否为真来调用不同的方法,并传入FORBIDDEN_BACKSLASH参数。查看这个FORBIDDEN_BACKSLASH属性的源码:

1
private static final List<String> FORBIDDEN_BACKSLASH = Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));

开发者如果希望请求地址允许出现正反斜杠,可以通过setAllowBackSlash(true)并且setAllowUrlEncodedSlash(true)这两个方法来进行设置。将之前httpFirewall方法中的代码修改为如下所示:

1
2
3
4
5
6
7
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowUrlEncodedSlash(true);
strictHttpFirewall.setAllowBackSlash(true);
return strictHttpFirewall;
}

请求地址中不能包含点号

当请求地址中包含点号(.)编码后的字符%2e或者%2E,那么该请求将会被拒绝。

查看一下StrictHttpFirewall#setAllowUrlEncodedPeriod方法的源码,如下所示:

1
2
3
4
5
6
7
public void setAllowUrlEncodedPeriod(boolean allowUrlEncodedPeriod) {
if (allowUrlEncodedPeriod) {
this.encodedUrlBlacklist.removeAll(FORBIDDEN_ENCODED_PERIOD);
} else {
this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
}
}

其中的Period就是点号,可以看到,它通过判断allowUrlEncodedPeriod属性(默认值为false)是否为真来调用不同的方法,并传入FORBIDDEN_ENCODED_PERIOD参数。查看这个FORBIDDEN_ENCODED_PERIOD属性的源码:

1
private static final List<String> FORBIDDEN_ENCODED_PERIOD = Collections.unmodifiableList(Arrays.asList("%2e", "%2E"));

开发者如果希望请求地址允许出现点号(.),可以通过setAllowUrlEncodedPeriod(true)这一方法来进行设置。将之前httpFirewall方法中的代码修改为如下所示:

1
2
3
4
5
6
@Bean
HttpFirewall httpFirewall(){
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowUrlEncodedPeriod(true);
return strictHttpFirewall;
}

小结

需要强调的是,上面所述的限制都是针对请求的RequestURI进行限制,而不是针对请求参数。当开发者的请求格式为:

1
http://localhost:8080/hello?name=envy

此时上述所述的限制对其没有任何影响。查看一下StrictHttpFirewall类中相关的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class StrictHttpFirewall implements HttpFirewall {
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
this.rejectForbiddenHttpMethod(request);
this.rejectedBlacklistedUrls(request);
this.rejectedUntrustedHosts(request);
if (!isNormalized(request)) {
throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
} else {
String requestUri = request.getRequestURI();
if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
throw new RequestRejectedException("The requestURI was rejected because it can only contain printable ASCII characters.");
} else {
return new FirewalledRequest(request) {
public void reset() {
}
};
}
}
}

private void rejectedBlacklistedUrls(HttpServletRequest request) {
Iterator var2 = this.encodedUrlBlacklist.iterator();

String forbidden;
do {
if (!var2.hasNext()) {
var2 = this.decodedUrlBlacklist.iterator();

do {
if (!var2.hasNext()) {
return;
}

forbidden = (String)var2.next();
} while(!decodedUrlContains(request, forbidden));

throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
}

forbidden = (String)var2.next();
} while(!encodedUrlContains(request, forbidden));

throw new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\"");
}

private static boolean encodedUrlContains(HttpServletRequest request, String value) {
return valueContains(request.getContextPath(), value) ? true : valueContains(request.getRequestURI(), value);
}

private static boolean decodedUrlContains(HttpServletRequest request, String value) {
if (valueContains(request.getServletPath(), value)) {
return true;
} else {
return valueContains(request.getPathInfo(), value);
}
}

private static boolean valueContains(String value, String contains) {
return value != null && value.contains(contains);
}
}

可以看到getFirewalledRequest方法获取的就是RequestURI

需要说明的是,开发者可以通过上述配置来手动修改SpringSecurity中默认的相关配置,但是笔者不建议开发者对上述配置进行修改,因为每一条限制规则都有它存在的理由,开发者仅仅为了,满足当前的需要而开放某条限制,可能会带来目前无法预知的安全风险,因此使用默认的配置能杜绝这种未知风险的发生。

ok,那么本篇关于SpringSecurity自带防火墙的学习就到此为止,后续学习其他内容。