写在前面 前面我们对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自带防火墙的学习就到此为止,后续学习其他内容。