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() {
        }
    };
}

可以看到,在返回对象之前,一共做了五个校验:

  1. rejectForbiddenHttpMethod:校验请求方法是否合法。

  2. rejectedBlacklistedUrls:校验请求中的非法字符。

  3. rejectedUntrustedHosts:检验主机信息。

  4. isNormalized:判断参数格式是否合法。

  5. 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 是否规范,对于不规范的请求将会直接拒绝掉。什么样的请求算是不规范的请求呢?

  1. 如果请求 URL 地址中在编码之前或者编码之后,包含了分号,即 ;、%3b、%3B,则该请求会被拒绝。可以通过 setAllowSemicolon 方法开启或者关闭这一规则。

  2. 如果请求 URL 地址中在编码之前或者编码之后,包含了斜杠,%2f、%2F,则该请求会被拒绝。可以通过 setAllowUrlEncodedSlash 方法开启或者关闭这一规则。

  3. 如果请求 URL 地址中在编码之前或者编码之后,包含了反斜杠,即 \\、%5c、%5C,则该请求会被拒绝。可以通过 setAllowBackSlash 方法开启或者关闭这一规则。

  4. 如果请求 URL 在编码之后包含了 %25,亦或者在编码之前包含了 %,则该请求会被拒绝。可以通过 setAllowUrlEncodedPercent 方法开启或者关闭这一规则。

  5. 如果请求 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 中的所有校验规则了。其中前三种,开发者可以通过相关方法调整参数进而调整校验行为,后面两种则不可调整。