Spring5.0.3 RequestRejectedException: 由于 URL 没有规范化,请求被拒绝

我不确定这是 Spring5.0.3的一个 bug,还是一个修复我这边问题的新特性。

升级之后,我得到了这个错误。有趣的是,这个错误只出现在我的本地机器上。在 HTTPS 协议的测试环境中,相同的代码可以很好地工作。

继续..。

之所以出现这个错误,是因为加载结果 JSP 页面的 URL 是 /location/thisPage.jsp。计算代码 request.getRequestURI()给出了结果 /WEB-INF/somelocation//location/thisPage.jsp。如果我将 JSP 页面的 URL 修改为这个 location/thisPage.jsp,那么一切都会很好。

所以我的问题是,我是否应该在代码中从 JSP路径中删除 /,因为这是今后所需要的。或者 Spring引入了一个 bug,因为我的机器和测试环境之间的唯一区别是协议 HTTP和协议 HTTPS

 org.springframework.security.web.firewall.RequestRejectedException: The request was rejected because the URL was not normalized.
at org.springframework.security.web.firewall.StrictHttpFirewall.getFirewalledRequest(StrictHttpFirewall.java:123)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:194)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:186)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
183427 次浏览

Spring Security Document 提到了在请求中阻塞//的原因。

For example, it could contain path-traversal sequences (like /../) or multiple forward slashes (//) which could also cause pattern-matches to fail. Some containers normalize these out before performing the servlet mapping, but others don’t. To protect against issues like these, FilterChainProxy uses an HttpFirewall strategy to check and wrap the request. Un-normalized requests are automatically rejected by default, and path parameters and duplicate slashes are removed for matching purposes.

所以有两种可能的解决方案

  1. 删除双斜杠(首选方法)
  2. 通过使用下面的代码自定义 StrictHttpFirewall,允许//在 Spring Security 中使用。

第一步 Create custom firewall that allows slash in URL.

@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
firewall.setAllowUrlEncodedSlash(true);
return firewall;
}

步骤2 然后在 websecurity 中配置这个 bean

@Override
public void configure(WebSecurity web) throws Exception {
//@formatter:off
super.configure(web);
web.httpFirewall(allowUrlEncodedSlashHttpFirewall());
....
}

步骤2是一个可选的步骤,Spring Boot 只需要声明一个类型为 HttpFirewall的 bean,它将在过滤器链中自动配置它。

Spring Security 5.4 Update

在 Spring security 5.4及以上版本(Spring Boot > = 2.4.0)中,我们可以通过创建下面的 bean 来摆脱太多抱怨请求被拒绝的日志。

import org.springframework.security.web.firewall.RequestRejectedHandler;
import org.springframework.security.web.firewall.HttpStatusRequestRejectedHandler;


@Bean
RequestRejectedHandler requestRejectedHandler() {
return new HttpStatusRequestRejectedHandler();
}

I encountered the same problem with:

Spring Boot version = 1.5.10
Spring Security version = 4.2.4


问题发生在端点上,其中 ModelAndView viewName 是用前面的 斜杠定义的。示例:

ModelAndView mav = new ModelAndView("/your-view-here");

如果我去掉斜杠,它会工作得很好。例如:

ModelAndView mav = new ModelAndView("your-view-here");

我还用 重定向视图做了一些测试,它似乎可以使用前面的斜杠。

setAllowUrlEncodedSlash(true)对我来说不起作用。当有双斜杠时,内部方法 isNormalized仍然返回 false

我用 DefaultHttpFirewall代替了 StrictHttpFirewall,只用了以下代码:

@Bean
public HttpFirewall defaultHttpFirewall() {
return new DefaultHttpFirewall();
}

对我来说效果不错。
使用 DefaultHttpFirewall有风险吗?

下面的解决方案是一个干净的工作周围。它不危害安全,因为我们使用相同的严格的防火墙。

修复的步骤如下:

步骤1: 创建一个覆盖 防火墙的类,如下所示。

package com.biz.brains.project.security.firewall;


import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;


import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


import org.springframework.http.HttpMethod;
import org.springframework.security.web.firewall.DefaultHttpFirewall;
import org.springframework.security.web.firewall.FirewalledRequest;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.RequestRejectedException;


public class CustomStrictHttpFirewall implements HttpFirewall {
private static final Set<String> ALLOW_ANY_HTTP_METHOD = Collections.unmodifiableSet(Collections.emptySet());


private static final String ENCODED_PERCENT = "%25";


private static final String PERCENT = "%";


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


private static final List<String> FORBIDDEN_SEMICOLON = Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));


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


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


private Set<String> encodedUrlBlacklist = new HashSet<String>();


private Set<String> decodedUrlBlacklist = new HashSet<String>();


private Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();


public CustomStrictHttpFirewall() {
urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);


this.encodedUrlBlacklist.add(ENCODED_PERCENT);
this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
this.decodedUrlBlacklist.add(PERCENT);
}


public void setUnsafeAllowAnyHttpMethod(boolean unsafeAllowAnyHttpMethod) {
this.allowedHttpMethods = unsafeAllowAnyHttpMethod ? ALLOW_ANY_HTTP_METHOD : createDefaultAllowedHttpMethods();
}


public void setAllowedHttpMethods(Collection<String> allowedHttpMethods) {
if (allowedHttpMethods == null) {
throw new IllegalArgumentException("allowedHttpMethods cannot be null");
}
if (allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
this.allowedHttpMethods = ALLOW_ANY_HTTP_METHOD;
} else {
this.allowedHttpMethods = new HashSet<>(allowedHttpMethods);
}
}


public void setAllowSemicolon(boolean allowSemicolon) {
if (allowSemicolon) {
urlBlacklistsRemoveAll(FORBIDDEN_SEMICOLON);
} else {
urlBlacklistsAddAll(FORBIDDEN_SEMICOLON);
}
}


public void setAllowUrlEncodedSlash(boolean allowUrlEncodedSlash) {
if (allowUrlEncodedSlash) {
urlBlacklistsRemoveAll(FORBIDDEN_FORWARDSLASH);
} else {
urlBlacklistsAddAll(FORBIDDEN_FORWARDSLASH);
}
}


public void setAllowUrlEncodedPeriod(boolean allowUrlEncodedPeriod) {
if (allowUrlEncodedPeriod) {
this.encodedUrlBlacklist.removeAll(FORBIDDEN_ENCODED_PERIOD);
} else {
this.encodedUrlBlacklist.addAll(FORBIDDEN_ENCODED_PERIOD);
}
}


public void setAllowBackSlash(boolean allowBackSlash) {
if (allowBackSlash) {
urlBlacklistsRemoveAll(FORBIDDEN_BACKSLASH);
} else {
urlBlacklistsAddAll(FORBIDDEN_BACKSLASH);
}
}


public void setAllowUrlEncodedPercent(boolean allowUrlEncodedPercent) {
if (allowUrlEncodedPercent) {
this.encodedUrlBlacklist.remove(ENCODED_PERCENT);
this.decodedUrlBlacklist.remove(PERCENT);
} else {
this.encodedUrlBlacklist.add(ENCODED_PERCENT);
this.decodedUrlBlacklist.add(PERCENT);
}
}


private void urlBlacklistsAddAll(Collection<String> values) {
this.encodedUrlBlacklist.addAll(values);
this.decodedUrlBlacklist.addAll(values);
}


private void urlBlacklistsRemoveAll(Collection<String> values) {
this.encodedUrlBlacklist.removeAll(values);
this.decodedUrlBlacklist.removeAll(values);
}


@Override
public FirewalledRequest getFirewalledRequest(HttpServletRequest request) throws RequestRejectedException {
rejectForbiddenHttpMethod(request);
rejectedBlacklistedUrls(request);


if (!isNormalized(request)) {
request.setAttribute("isNormalized", new RequestRejectedException("The request was rejected because the URL was not normalized."));
}


String requestUri = request.getRequestURI();
if (!containsOnlyPrintableAsciiCharacters(requestUri)) {
request.setAttribute("isNormalized",  new RequestRejectedException("The requestURI was rejected because it can only contain printable ASCII characters."));
}
return new FirewalledRequest(request) {
@Override
public void reset() {
}
};
}


private void rejectForbiddenHttpMethod(HttpServletRequest request) {
if (this.allowedHttpMethods == ALLOW_ANY_HTTP_METHOD) {
return;
}
if (!this.allowedHttpMethods.contains(request.getMethod())) {
request.setAttribute("isNormalized",  new RequestRejectedException("The request was rejected because the HTTP method \"" +
request.getMethod() +
"\" was not included within the whitelist " +
this.allowedHttpMethods));
}
}


private void rejectedBlacklistedUrls(HttpServletRequest request) {
for (String forbidden : this.encodedUrlBlacklist) {
if (encodedUrlContains(request, forbidden)) {
request.setAttribute("isNormalized",  new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\""));
}
}
for (String forbidden : this.decodedUrlBlacklist) {
if (decodedUrlContains(request, forbidden)) {
request.setAttribute("isNormalized",  new RequestRejectedException("The request was rejected because the URL contained a potentially malicious String \"" + forbidden + "\""));
}
}
}


@Override
public HttpServletResponse getFirewalledResponse(HttpServletResponse response) {
return new FirewalledResponse(response);
}


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 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;
}


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


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


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;
}


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


private static boolean isNormalized(String path) {
if (path == null) {
return true;
}


if (path.indexOf("//") > -1) {
return false;
}


for (int j = path.length(); j > 0;) {
int i = path.lastIndexOf('/', j - 1);
int gap = j - i;


if (gap == 2 && path.charAt(i + 1) == '.') {
// ".", "/./" or "/."
return false;
} else if (gap == 3 && path.charAt(i + 1) == '.' && path.charAt(i + 2) == '.') {
return false;
}


j = i;
}


return true;
}


}

STEP 2 : Create a FirewalledResponse class

package com.biz.brains.project.security.firewall;


import java.io.IOException;
import java.util.regex.Pattern;


import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;


class FirewalledResponse extends HttpServletResponseWrapper {
private static final Pattern CR_OR_LF = Pattern.compile("\\r|\\n");
private static final String LOCATION_HEADER = "Location";
private static final String SET_COOKIE_HEADER = "Set-Cookie";


public FirewalledResponse(HttpServletResponse response) {
super(response);
}


@Override
public void sendRedirect(String location) throws IOException {
// TODO: implement pluggable validation, instead of simple blacklisting.
// SEC-1790. Prevent redirects containing CRLF
validateCrlf(LOCATION_HEADER, location);
super.sendRedirect(location);
}


@Override
public void setHeader(String name, String value) {
validateCrlf(name, value);
super.setHeader(name, value);
}


@Override
public void addHeader(String name, String value) {
validateCrlf(name, value);
super.addHeader(name, value);
}


@Override
public void addCookie(Cookie cookie) {
if (cookie != null) {
validateCrlf(SET_COOKIE_HEADER, cookie.getName());
validateCrlf(SET_COOKIE_HEADER, cookie.getValue());
validateCrlf(SET_COOKIE_HEADER, cookie.getPath());
validateCrlf(SET_COOKIE_HEADER, cookie.getDomain());
validateCrlf(SET_COOKIE_HEADER, cookie.getComment());
}
super.addCookie(cookie);
}


void validateCrlf(String name, String value) {
if (hasCrlf(name) || hasCrlf(value)) {
throw new IllegalArgumentException(
"Invalid characters (CR/LF) in header " + name);
}
}


private boolean hasCrlf(String value) {
return value != null && CR_OR_LF.matcher(value).find();
}
}

STEP 3: Create a custom Filter to suppress the RejectedException

package com.biz.brains.project.security.filter;


import java.io.IOException;
import java.util.Objects;


import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.security.web.firewall.RequestRejectedException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;


import lombok.extern.slf4j.Slf4j;


@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestRejectedExceptionFilter extends GenericFilterBean {


@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
try {
RequestRejectedException requestRejectedException=(RequestRejectedException) servletRequest.getAttribute("isNormalized");
if(Objects.nonNull(requestRejectedException)) {
throw requestRejectedException;
}else {
filterChain.doFilter(servletRequest, servletResponse);
}
} catch (RequestRejectedException requestRejectedException) {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
log
.error(
"request_rejected: remote={}, user_agent={}, request_url={}",
httpServletRequest.getRemoteHost(),
httpServletRequest.getHeader(HttpHeaders.USER_AGENT),
httpServletRequest.getRequestURL(),
requestRejectedException
);


httpServletResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
}

步骤4: 在安全配置中将自定义过滤器添加到弹簧过滤器链

@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(new RequestRejectedExceptionFilter(),
ChannelProcessingFilter.class);
}

现在使用上面的修复程序,我们可以处理 RequestRejectedException错误404页。

一旦我在调用 API 时使用了双斜杠,就会得到相同的错误。

我不得不调用 http://localhost:8080/getSomething,但我做了喜欢 http://localhost:8080//getSomething。我解决了它删除额外的斜杠。

在我的案例中,这个问题是由于没有用 Postman 登录造成的,所以我用从 Chrome 会话的头部获取的会话 cookie 在另一个选项卡中打开了一个连接。

在我的例子中,从 spring-security-web 3.1.3升级到4.2.12,默认情况下 defaultHttpFirewallDefaultHttpFirewall更改为 StrictHttpFirewall。 所以只需要像下面这样在 XML 配置中定义它:

<bean id="defaultHttpFirewall" class="org.springframework.security.web.firewall.DefaultHttpFirewall"/>
<sec:http-firewall ref="defaultHttpFirewall"/>

HTTPFirewall设置为 DefaultHttpFirewall