本文摘自江南一点雨Spring Security的网课笔记。全文敲下来的目的有两个,一是温习知识点,二是学习写文章的技巧。
HttpFirewall是Spring Security提供的HTTP防火墙,它可以用于拒绝潜在危险请求或者包装这些请求进而控制其行为。通过HttpFirewall可以对各种非法请求提前拦截并处理,降低损失。代码层面,HttpFirewall被注入到FilterChainProxy中。并在Spring Security过滤器链执行之前被触发。
本章涉及的主要知识点有:
- HttpFirewall简介
- HttpFirewall 严格模式
- HttpFirewall 普通模式
一 HttpFirewall 简介
在servlet容器规范中,为HttpServletRequest定义了一些属性,如contextPath、servletPath、pathInfo、queryString等,这些属性我们都可以通过get方法获取。然而在servlet容器规范中并没有定义这些属性都可以包含哪些值,例如在servletPath和pathInfo中都可以包含RFC2396的规范(www.ietf.org/rfc/rfc2396…)中定义的参数,不同容器对此处理方案也不同,有的容器会对此进行预处理,有的容器则不会。这种比较混乱的处理方式可能会造成安全隐患。因此Spring Security中通过HttpFirewall来检查请求路径和参数是否合法,如果合法,才会进入到过滤器链中进行处理。
从代码层面看,HttpFirewall是一个接口,它只有两个方法。
public interface HttpFirewall {
/**
* Provides the request object which will be passed through the filter chain.
* @throws RequestRejectedException if the request should be rejected immediately
*/
FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException;
/**
* Provides the response which will be passed through the filter chain.
* @param response the original response
* @return either the original response or a replacement/wrapper.
*/
HttpServletResponse getFirewalledResponse(HttpServletResponse response);
}
getHttpFirewalledRequest用于对请求对象进行检验并封装,getHttpFirewalledResponse方法则对响应对象进行封装。
FirewallRequest是封装后的请求对象。但实际上该类只是在HttpServletWrapper的基础上增加了reset方法,当 Spring Security 过滤器链执行完毕时,由 FilterChainProxy 负责调用该 reset 方法,以便重置全部或者部分属性。
FirewalledResponse是封装后的响应类,该类主要重写了sendRedirect、setHeader、addHeader以及addCookie四个方法。在每一个方法中都对其参数进行了校验,以确保参数中不含\n和\r。
HttpFirewall一共有两个实现类,如图所示
- DefaultHttpFirewall:虽然名字中包含default,但这并不是框架默认使用的Http防火墙。只是一个检查相对宽松的防火墙。
- StrictHttpFirewall:这是一个检查严格的Http防火墙,也是框架默认使用的Http防火墙。
HttpFirewall中对请求合法性的校验在FilterChainProxy#doFilterInternal 中触发。
需要注意的是HttpFirewall的配置位置,在Spring Security中有两个地方涉及到了HttpFirewall实例的获取:
- 在FilterChainProxy属性定义中,默认创建的HttpFirewall实例就是StrrictHttpFirewall。
- FilterChainProxy是在WebSecurity#performBuild方法中构建的。而webSecurity实现了ApplicationContextAware接口。并实现了setApplicationContext方法。在该方法中从Spring容器中查找到HttpFirewall对象并赋值给httpFirewall属性,最终在performBuild方法中,将FilterChainProxy对象构建完成之后,如果httpFirewall不为null,就把httpFirewall配置给FilterChainProxy对象
根据以上两点可以得出:如果Spring容器中存在HttpFirewall实例,则最终使用Spring容器中提供的HttpFirewall实例,如果Spring容器中不存在HttpFirewall实例,则使用FilterChainProxy中默认提供的StrictHttpFirewall。进而可知,如果开发者不想使用默认的StrictHttpFirewall实例,只需要自己提供一个HttpFirewall实例即可。
二 HttpFirewall严格模式
HttpFirewall严格模式就是使用StrictHttpFirewall,默认即,我们来对严格模式中的规则逐一进行分析分析。
在FilterChainProxy#doFilterInternal中触发请求校验的方法如下。
private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain){
FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
.....
vfc.doFilter(firewallRequest, firewallResponse);
}
可以看到,请求的校验主要是在getFirewallRequest方法中完成的。在进入Sping Security过滤器之前,请求对象和响应对象都分别换成FirewallRequest和FirewallResponse了。如前面所述,FirewallResponse主要对响应头请求参数进行校验,比较简单,笔者这里不再赘述。不过需要注意的是,无论是FirewallRequest还是FirewallResponse,在经过spring securiity 过滤器链处理的时候,还会通过装饰器模式增强其功能,所以开发者在接口拿到的HttpServletRequest和HttpServletResponse都不是此处的FirewallRequest和FirewallResponse。
我们来重点分析getFirewallRequest方法中所做的校验。
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
rejectForbiddenHttpMethod(request);
rejectedBlocklistedUrls(request);
rejectedUntrustedHosts(request);
if (!isNormalized(request)) {
throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
}
rejectNonPrintableAsciiCharactersInFieldName(request.getRequestURI(), "requestURI");
return new StrictFirewalledRequest(request);
}
可以看到,在返回对象之前,一共做了五个校验:
- rejectForbiddenHttpMethod:检验请求方法是否合法
- rejectedBlacklistedUrls: 检验请求中的非法字符
- rejectedUntrustedHosts: 检验主机信息。
- isNormalized: 判断参数格式是否合法
- containsOnlyPrintableAsciiCharacters:判断请求字符是否合法
我们来逐一分析这五个方法
2.1rejectForbiddenHttpMethod
rejectForbiddenHttpMethods主要用来判断请求方法是否合法:
private void rejectForbiddenHttpMethod(HttpServletRequest request) {
if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
return;
}
if (!this.allowedHttpMethods.contains(request.getMethod())) {
throw new RequestRejectedException(
"The request was rejected because the HTTP method "" + request.getMethod()
+ "" was not included within the list of allowed HTTP methods " + this.allowedHttpMethods);
}
}
allowedHttpMethods是一个Set集合,默认情况下这个集合包含七个常见的方法:DELETE,GET,POST,PATCH,PUTOPTIONS,HEAD,ALLOW_ANY_HTTP_METHOD变量默认情况下则是一个空的Set集合。根据rejectForbiddenHttpMethod方法中的定义,只要你的请求方法是这七个中的任意一个,请求都是可以通过的,不会被拦截。当然开发者也可以根据实际需求修改allowedHttpMethods变量的值,进而调整允许的请求方法。第一种修改方式如下:
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
Set<String> allowedHttpMethods = new HashSet<>();
allowedHttpMethods.add(HttpMethod.POST.name());
strictHttpFirewall.setAllowedHttpMethods(allowedHttpMethods);
return strictHttpFirewall;
}
//省略其他
}
由开发者自己提供一个HttpFirewall实例,并调用setAllowedHttpMethods方法来传入一个Set集合,集合中保存着允许通过请求的方法,这个集合最终会被赋值给allowedHttpMethods变量。配置完成后。重启项目,此时再去访问,就只有POST请求可以被处理了,如果发送GET请求,服务端将抛出异常,如下:
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the HTTP method "GET" was not included within
第二种修改方式如下:
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setUnsafeAllowAnyHttpMethod(true);
return strictHttpFirewall;
}
这种方式是直接调用setUnsafeAllowAnyHttpMethod方法并设置参数为true,表示允许所有的请求通过。该方法会设置让allowedHttpMethods等于ALLOW_ANY_HTTP_METHOD,这样会导致rejectForbiddenHttpMethod方法的第一个if分支中直接返回,进而达到允许所有请求访问的目的。、
2.2 rejectedBlacklistedUrls
rejectedBlacklistedUrls 主要用来校验请求URL是否规范,对于不规范的请求将会直接拒绝掉。什么样的请求算是不规范的请求呢?
- 如果请求URL地址在编码之前或者编码之后,包含了分号,即;、%3b、%3B,则该请求会被拒绝,可以通过setAllowSemicolon方法开启或者关闭这一规则。
- 如果请求URL地址在编码之前或者编码之后,包含了斜杠,即%2f、%2F,则该请求会被拒绝,可以通过setAllowEncodedSlash开启或关闭这一规则。
- 如果请求URL地址中在编码之前或者编码之后,包含了反斜杠,即\、%5c、%5C,则该请求会被拒绝,可以通过setAllowBackSlash开启或者关闭这一规则。
- 如果请求URL在编码之后包含了%25,亦或者在编码之前包含了%,则该请求会被拒绝,可以通过setAllowUrlEncodePercent方法开启或者关闭这一规则。
- 如果请求URL在URL在编码后包含了英文句号%2e或者%2E,则该请求会被拒绝。可以通过setAllowUrlEncodedPeriod方法开启或者关闭这一规则。
private void rejectedBlacklistedUrls(HttpServletRequest request) {
for (String forbidden : this.encodedUrlBlacklist) {
if (encodedUrlContains(request, forbidden)) {
throw new RequestRejectedException("The request was rejected
because the URL contained a potentially malicious String ""
+ forbidden + """);
}
}
for (String forbidden : this.decodedUrlBlacklist) {
if (decodedUrlContains(request, forbidden)) {
throw new RequestRejectedException("The request was rejected
because the URL contained a potentially malicious String ""
+ forbidden + """);
}
}
}
这里一共包含两个for循环。第一个校验编码后的请求地址,第二个校验解码后的请求地址。
在encodedUrlContains方法中我们可以看到,这里主要是检验了contextPath和requestURI两个属性,这两个属性是客户端传递过来的字符串,未做任何更改。
而在decodedUrlContains方法中 ,则主要校验了ServletPath、pathInfo两个属性,读者可能会觉得这不是重复校验了吗?前面requestURI已经包含所有了!
这里大家需要注意,requestURI是客户端发来的请求,是原封不动的,而servletPath和pathInfo则是经过解码的请求地址,所以两者是不一样的。例如客户端发送的请求是 http://localhost:8080/get%3baaa ,那么requestURI的值就是/get%3baaa,而saervletPath的值则是/get;aaa(假设没有contextPath),即在servletPath中,将%3b还原成;了。
如果请求地址中含有不规范字符,例如请求 http://localhost:8080/get%3baaa地址,控制台报错如下:
org.springframework.security.web.firewall.RequestRejectedException: The 2 request was rejected because the URL contained a potentially malicious String 3 "%3b
2.3 rejectedBlacklistedUrls
rejectedUntrustedHosts 方法主要用来校验Host是否受信任
private void rejectedUntrustedHosts(HttpServletRequest request) {
String serverName = request.getServerName();
if (serverName != null && !this.allowedHostnames.test(serverName)) {
throw new RequestRejectedException("The request was rejected because the domain " + serverName + " is untrusted.");
}
}
从这里可以看出是对serverName的校验,allowedHostNames默认总是返回true,即默认信任所有的Host。开发者可以根据需求对此进行配置,如下:
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setAllowedHostnames(
(hostname) -> hostname.equalsIgnoreCase("local.javaboy.org"));
return strictHttpFirewall;
}
这段配置表示Host必须是local.javaboy.org其他Host将不被信任,配置完成后重启项目,此时如果访问http://localhost:8080/get,控制台将会报错,如下:
org.springframework.security.web.firewall.RequestRejectedException: 2 The request was rejected because the domain localhost is untrusted.
使用 http://local.javaboy.org:8080/get 地址则可以正常访问。
2.4 isNormalized
isNormalized 主要用来检查请求地址是否规范,什么样的地址就算规范呢?即不包含 "./"、"/../" 以及 "/." 三种字符串。
private static boolean isNormalized(HttpServletRequest request) {
if (!isNormalized(request.getRequestURI())) {
return false;
}
if (!isNormalized(request.getContextPath())) {
return false;
}
if (!isNormalized(request.getServletPath())) {
return false;
}
if (!isNormalized(request.getPathInfo())) {
return false;
}
return true;
}
可以看到,该方法对requestURI、contextPath、servletPath、pathInfo分别进行了校验。
如果开发者请求local.javaboy.org:8080/get/../地址,则控制台会报错如下:
org.springframework.security.web.firewall.RequestRejectedException: The 2 request was rejected because the URL was not normalized.
2.5 containsOnlyPrintableAsciiCharacters
containsOnlyPrintableAsciiCharacters方法用来校验请求地址中是否包含不可打印的ASCII字符
private static boolean containsOnlyPrintableAsciiCharacters(String uri) {
int length = uri.length();
for (int i = 0; i < length; i++) {
char c = uri.charAt(i);
if (c < '\u0020' || c > '\u007e') {
return false;
}
}
return true;
}
可打印的ASCII字符范围在'\u0020'到'\u007e'之间,对应的十进制就是32-126之间,在此范围之外的,属于是不可打印的ASCII字符。
这就是StrictHttpFirewall中所有的校验规则了,其中前三种开发者可以通过相关方法调整参数进而调整校验行为,后面两种则不可调整。
三 HttpFirewall 普通模式
HttpFirewall普通模式就是使用DefaultHttpFirewall,该类的校验规则就要简单很多,我们也来看下其getFirewallRequest方法
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
FirewalledRequest firewalledRequest = new RequestWrapper(request);
if (!isNormalized(firewalledRequest.getServletPath()) || !isNormalized(firewalledRequest.getPathInfo())) {
throw new RequestRejectedException(
"Un-normalized paths are not supported: " + firewalledRequest.getServletPath()
+ ((firewalledRequest.getPathInfo() != null) ? firewalledRequest.getPathInfo() : ""));
}
String requestURI = firewalledRequest.getRequestURI();
if (containsInvalidUrlEncodedSlash(requestURI)) {
throw new RequestRejectedException("The requestURI cannot contain encoded slash. Got " + requestURI);
}
return firewalledRequest;
}
可以看,首先是构建了requestWrapper对象对原始请求中的功能进行了增强,在requestWrapper构建过程中,主要干了两件事:
- 将请求地址中的// 格式化为/
- 将请求中servletPath和pathInfo中用分号分隔的参数提取出来,只保留路径即可。
举个简单例子,例如你的请求地址是 http://localhost:8080//get,假设不存在contextPath,那么在原始请求中获取到的servletPath就是//get,而在fwr对象中获取到的servletPath就是/get。
需要注意的是Tmcat容器本身会自动的将//转为/,而jetty和Undertow则不会,这三个容器默认都会自动将用分号隔开的参数剔除,只保留请求路径。
获取到fwr对象之后,接下来调用isNormalized方法判断servletPath和pathInfo是否规范,判断逻辑和StrictHttpFirewall中逻辑一致,这里不再赘述。
containsInvalidUrlEncodedSlash 用来判断requestURI中是否包含编码后的斜杠,即%2f或者%2F,默认是不包含此字符,如果开发者需要,可以通过setAllowUrlEncodedSlash 设置%2f或者%2F存在。一般来说并不建议开发者在项目中使用DefaultHttpFirewall,因为相比于StrictHttpFirewall,DefaultHttpFirewall的安全性要差很多,如果开发者一定要使用,只需要提供一个DefaultHttpFirewall的实例即可。
@Bean
HttpFirewall httpFirewall() {
return new DefaultHttpFirewall();
}
四 小结
本章主要和读者分析了 Spring Security 中的防火墙机制。其实 HttpFirewall 所做的事情有一部分在 Servlet 容器中已经做了,然而由于不同 Servlet 容器的差异性带来了一定的安全风险,而 HttpFirewall 则屏蔽了这种风险,将所有的请求地址统一规范,确保进入到 Spring Security 过滤器链中的请求地址都是合法地址。