If you use the UrlHandlerFilter to ignore or remove trailing slash in URLs, when you call RequestDispatcher.forward() from a filter, a servlet or a controller, it will not work as expected if the requested url ends with slash.
For example you configure this filter as the first one in your server:
@Bean
public Filter urlTrailingSlashFilter() {
return UrlHandlerFilter
.trailingSlashHandler("/**").wrapRequest()
.build();
}
If the request does not end with slash, the original request is used in any call to request.getRequestDispatcher("/other_path").forward(request, response);
At least using Tomcat, that makes the request getRedirectURI method to return "/other_path" in the destination servlet, so if the request is processed by the Spring DispatcherServlet, it is correctly routed to any controller mapped in "/other_path".
If the request ends with slash, the original request is changed by a TrailingSlashHttpServletRequest request wrapper that overrides getRedirectURI, getRedirectURL, getServletPath and getPathInfo.
So when you use that request in a forward, the destination servlet doesn't see in the request the path used in the getRequestDispatcher call because it is overridden by the wrapper. The Spring DispatcherServlet will route the request to the original controller instead of the controller mapped to the path you used in the getRequestDispatcher call.
If you have for example a custom security filter that wants to forward an invalid or unauthorised request to a given controller using RequestDispatcher.forward() to be processed by spring DispatcherServlet and routed to a new controller, it's not going to work well when the request ends with slash.
Before Spring 6 this was not a problem because the way to ignore trailing slash was different. Now the behaviour of the ".forward()" request depends on if the slash has been removed or not by the UrlHandlerFilter filter.
A solution could be to test in theTrailingSlashHttpServletRequest wrapper if the request is not a direct request, using for example:
request.getDispatcherType() == DispatcherType.REQUEST
We have made a custom filter similar to UrlHandlerFilter that returns a request wrapper that is aware of the request.getDispatcherType(). It only overrides the URl methods with the version without slash when the getDispatcherType is DispatcherType.REQUEST, so the calls to the forward methods an other RequestDispatcher mechanism works as expected.
Comment From: rstoyanchev
I was able to reproduce with the below on Tomcat with the forward coming back to the same path method over and over, in effect recursing.
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
public Filter urlTrailingSlashFilter() {
return UrlHandlerFilter
.trailingSlashHandler("/**").wrapRequest()
.build();
}
@RestController
public static class TestController {
@GetMapping("/path")
public void path(HttpServletRequest request, HttpServletResponse response) throws Exception {
request.getRequestDispatcher("/other_path").forward(request, response);
}
@GetMapping("/other_path")
public String otherPath() {
return "success";
}
}
}
From what I can see Tomcat's ApplicationDispatcher#wrapRequest unwraps the requests until it finds the Tomcat request, and then applies a new wrapper around it with knowledge of the forwarded path. So while the outermost request is used for the forward dispatch, the knowledge of the target path is deeper in the chain of wrapped requests, at the level of Tomcat's ApplicationHttpRequest. That means custom request wrappers should either not interfere with the path during forwarding, or should otherwise be aware of the forwarded path.
On Jetty, the same scenario actually works as expected. The difference is that Jetty wraps the outermost request and adds knowledge to it of the forwarded path, in effect overriding the path of the outermost request.
Maybe there is an opportunity for an improvement in Tomcat, but we can also make an improvement by calling the delegate request for path information when the dispatcher type is FORWARD. At that point there is a new path, and the original path is of no use in any case.
Comment From: nitroduna
I haven't gone into depth about what would be the best solution. For now, I'm am using this filter to solve the problem in our server. I return the original URL in all cases except DispatcherType.REQUEST: That is, FORWARD, ERROR and INCLUDE requests don't override the URL. (Maybe I should also exclude ASYNC, but our code is not using ASYNC requests).
I'm sending you the code in case it helps (there are pieces copied from the UrlHandlerFilter file).
public class RemoveTrailingSlashFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain
) throws ServletException, IOException {
if (!request.getRequestURI().endsWith("/")) {
filterChain.doFilter(request, response);
return;
}
var servletPath = request.getServletPath();
var pathInfo = request.getPathInfo();
var hasPathInfo = StringUtils.hasText(pathInfo);
request = new RemoveTrailingSlashFilterRequest(
request,
trimTrailingSlash(request.getRequestURI()),
trimTrailingSlash(request.getRequestURL().toString()),
hasPathInfo ? servletPath : trimTrailingSlash(servletPath),
hasPathInfo ? trimTrailingSlash(pathInfo) : pathInfo
);
filterChain.doFilter(request, response);
}
private String trimTrailingSlash(String path) {
int index = (StringUtils.hasLength(path) ? path.lastIndexOf('/') : -1);
return (index != -1 ? path.substring(0, index) : path);
}
private static class RemoveTrailingSlashFilterRequest extends HttpServletRequestWrapper {
private final String requestURI;
private final StringBuffer requestURL;
private final String servletPath;
private final String pathInfo;
public RemoveTrailingSlashFilterRequest(
HttpServletRequest request, String requestURI, String requestURL, String servletPath, String pathInfo
) {
super(request);
this.requestURI = requestURI;
this.requestURL = new StringBuffer(requestURL);
this.servletPath = servletPath;
this.pathInfo = pathInfo;
}
@Override
public String getRequestURI() {
return isDirectRequest() ? this.requestURI : super.getRequestURI();
}
@Override
public StringBuffer getRequestURL() {
return isDirectRequest() ? this.requestURL : super.getRequestURL();
}
@Override
public String getServletPath() {
return isDirectRequest() ? this.servletPath : super.getServletPath();
}
@Override
public String getPathInfo() {
return isDirectRequest() ? this.pathInfo : super.getPathInfo();
}
private boolean isDirectRequest() {
return getDispatcherType() == DispatcherType.REQUEST;
}
}
}