HttpFirewall严格模式
HttpFirewall 严格模式就是使用 StrictHttpFirewall,默认即此。本节我们将对严格模式中的规则逐一进行分析。
在 FilterChainProxy#doFilterInternal 中触发请求校验的方法如下:
private void doFilterInternal(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
FirewalledRequest fwRequest = firewall
.getFirewalledRequest((HttpServletRequest) request);
HttpServletResponse fwResponse = firewall
.getFirewalledResponse((HttpServletResponse) response);
// 省略其它
vfc.doFilter(fwRequest, fwResponse);
}
可以看到,请求的校验主要是在 getFirewalledRequest 方法中完成的。在进入 Spring Security 过滤器链之前,请求对象和响应对象都分别换成 FirewalledRequest 和 FirewalledResponse 了。
如前面所述,FirewalledResponse 主要对响应头参数进行校验,比较简单,这里不再述。不过需要注意的是,无论是 FirewalledRequest 还是 FirewalledResponse,在经过 Spring Security 过滤器链的时候,还会通过装饰器模式增强其功能,所以开发者最终在接口中掌到的 HttpServletRequest 和 HttpServletResponse 对象,并不是这里的 FirewalledRequest 和 FirewalledResponse。
我们来重点分析 getFirewalledRequest 方法中所做的校验。
StrictHttpFirewall#getFirewalledRequest 源码如下:
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
rejectForbiddenHttpMethod(request);
rejectedBlacklistedUrls(request);
rejectedUntrustedHosts(request);
if (!isNormalized(request)) {
throw new RequestRejectedException("The request was rejected because the URL was not normalized.");
}
String requestUri = request.getRequestURI();
if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
throw new RequestRejectedException("The requestURI was rejected because it can only contain printable ASCII characters.");
}
return new FirewalledRequest(request) {
@Override
public void reset() {
}
};
}
可以看到,在返回对象之前,一共做了五个校验:
-
rejectForbiddenHttpMethod:校验请求方法是否合法。
-
rejectedBlacklistedUrls:校验请求中的非法字符。
-
rejectedUntrustedHosts:检验主机信息。
-
isNormalized:判断参数格式是否合法。
-
containsOnlyPrintableAsciiCharacters:判断请求字符是否合法。
下面,我们来逐一分析这五个校验方法。
rejectForbiddenHttpMethod
rejectForbiddenHttpMethod 方法主要用来判断请求方法是否合法:
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 whitelist " +
this.allowedHttpMethods);
}
}
allowedHttpMethods 是一个 Set 集合,默认情况下该集合中包含七个常见的方法:DELETE、GET、HEAD、OPTIONS、PATCH、POST、PUT,ALLOW ANY HTTP METHOD 变量默认情况下则是一个空的 Set 集合。根据 rejectForbiddenHttpMethod 方法中的定义,只要你的请求方法是这七个中的任意一个,请求都是可以通过的,不会被拦截。当然开发者也可以根据实际需求修改 allowedHttpMethods 变量的值,进而调整充许的请求方法。
第一种修改方式如下:
import java.util.HashSet;@Configuration
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 集合,集合中保存着允许通过的请求方法,这个集合最终会被赋值给 allowedHtpMethods 变量。配置完成后,重启项目,此时再去访问,就只有 POST 请求可以被处理了,如果发送 GET 请求,那服务端将抛出异常,代码如下:
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the HTTP method "GET" was not included within the whitelist [POST]
第二种修改方式如下:
@Bean
HttpFirewall httpFirewall() {
StrictHttpFirewall strictHttpFirewall = new StrictHttpFirewall();
strictHttpFirewall.setUnsafeAllowHttpMethod(true);
return strictHttpFirewall;
}
这种方式是直接调用 setUnsafeAllowAnyHttpMethod 方法并设置参数为 true,表示允许所有的请求通过。该方法会设置让 allowedHttpMethods 等于 ALLOW_ANY_HTTP_METHOD,这样会导致在 rejectForbiddenHttpMethod 方法的第一个 if 分支中直接返回,进而达到允许所有请求通过的目的。
rejectedBlacklistedUrls
rejectedBlacklistedUrls 主要用来校验请求 URL 是否规范,对于不规范的请求将会直接拒绝掉。什么样的请求算是不规范的请求呢?
-
如果请求 URL 地址中在编码之前或者编码之后,包含了分号,即 ;、%3b、%3B,则该请求会被拒绝。可以通过 setAllowSemicolon 方法开启或者关闭这一规则。
-
如果请求 URL 地址中在编码之前或者编码之后,包含了斜杠,%2f、%2F,则该请求会被拒绝。可以通过 setAllowUrlEncodedSlash 方法开启或者关闭这一规则。
-
如果请求 URL 地址中在编码之前或者编码之后,包含了反斜杠,即 \\、%5c、%5C,则该请求会被拒绝。可以通过 setAllowBackSlash 方法开启或者关闭这一规则。
-
如果请求 URL 在编码之后包含了 %25,亦或者在编码之前包含了 %,则该请求会被拒绝。可以通过 setAllowUrlEncodedPercent 方法开启或者关闭这一规则。
-
如果请求 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 的值就是 http://localhost:8080/get%3baaa ,而 servletPath 的值则是 /get;aaa(假设 contextPath 为空),即在 servletPath 中,将 %3b 还原为分号了。
如果请求地址中含有不规范字符,例如请求 http://localhost:8080/get%3baaa 地址,则控制台报错如下:
org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL contained a potentially malicious string "%3b"
rejectedUntrustedHosts
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.ReguestRejiectedException:
The reguest was reiected because the domain -oca-host is untrusted.
使用 http://local.javaboy.org:8080/get 地址则可以正常访问。
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 分别进行了校验。
如果开发者请求 http://local.javaboy.org:8080/get/../ 地址,则控制台报错如下:
org.springframework.security.web.firewall.ReguestRejectedException: The
reguest was reiected because the URL was not normalized.
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 中的所有校验规则了。其中前三种,开发者可以通过相关方法调整参数进而调整校验行为,后面两种则不可调整。