https://github.com/lasselindqvist/spring-dispatcher-demo contains example projects for both Spring Boot 2.7.13 and 3.1.1. But the problem seems to be in Spring, not Spring Boot.
Take a simple application with one REST controller and one servlet:
@RestController
public class HelloController {
@GetMapping("/api/hello")
public ResponseEntity<String> checkAlive() {
return ResponseEntity.ok().body("hello");
}
}
public class HelloServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest servletRequest, HttpServletResponse servletResponse)
throws ServletException, IOException {
servletResponse.setStatus(HttpServletResponse.SC_OK);
servletResponse.getWriter().write("hello from servlet");
servletResponse.getWriter().flush();
}
}
Add a logging dispatcher. The inner workings of this do not matter, but the logging is here used as just an example.
public class LoggingDispatcherServlet extends DispatcherServlet{
@Override
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) {
try {
System.out.println("dispatching");
super.doDispatch(request, response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
- Register the servlet to make it available.
- Register the logging dispatcher servlet.
- Make the logging dispatcher servlet available at both
/*
and at a subpath/servlet/*
. In a real case these dispatchers would be different somehow, but here the bug reproduces anyway, so for simplicity just use the same one. In a real case we might want to do different logging or something for calls that go to the servlet for example.
@Bean
public ServletRegistrationBean<DispatcherServlet> defaultDispatcherRegistration() {
return new ServletRegistrationBean<>(loggingDispatcherServlet(), "/*");
}
@Bean
public ServletRegistrationBean<DispatcherServlet> webdavDispatcherRegistration() {
return new ServletRegistrationBean<>(loggingDispatcherServlet(), "/servlet/*");
}
@Bean
public DispatcherServlet loggingDispatcherServlet() {
LoggingDispatcherServlet lds = new LoggingDispatcherServlet();
return lds;
}
@Bean
public ServletRegistrationBean<HelloServlet> servletBean() {
var servlet = new HelloServlet();
ServletRegistrationBean<HelloServlet> bean = new ServletRegistrationBean(
servlet, "/servlet/*");
bean.setLoadOnStartup(1);
return bean;
}
- GET http://localhost:8080/api/hello. Returns "hello"
- GET http://localhost:8080/servlet/abc. Returns "hello from servlet"
Change the project to use Spring Boot 3.1.1 (Spring 6 and Jakarta Servlet specs)
- GET http://localhost:8083/api/hello. Returns "hello"
- GET http://localhost:8083/servlet/abc. Returns
{
"timestamp": "2023-07-11T06:04:19.131+00:00",
"status": 404,
"error": "Not Found",
"path": "/servlet/abc"
}
If
@Bean
public ServletRegistrationBean<DispatcherServlet> webdavDispatcherRegistration() {
return new ServletRegistrationBean<>(loggingDispatcherServlet(), "/servlet/*");
}
is removed, the problem does not happen. The problem seems to relate to Spring helper class ServletRequestPathUtils and how it returns ServletRequestPath.pathWithinApplication without the "servlet" prefix and then the request does not go to the correct servlet anymore. This also affects security configuration where
auth.requestMatchers("/servlet").permitAll()
would no longer work. Using
RequestMatcher servletMatcher = (request) -> {
String servletPath = request.getServletPath();
boolean matches = servletPath.startsWith("/servlet");
return matches;
};
can be used here to work around it though.
Comment From: mdeinum
You cannot have multiple servlets on the same path, the container doesn't know what to handle it with. I suspect that the last one that is being registered will always win/be registered. As it will override a already registered servlet on the same path. Once you are inside a servlet you cannot go to another one and it isn't Spring (nor Spring Boot) that will handle the dispatching but rather your Servlet container.
I would even dare to say that this is an issue in the servlet container as the JakartaEE spec clearly states:
If the effective
web.xml
(after merging information from fragments and annotations) contains any url-patterns that are mapped to multiple servlets then the deployment must fail.
Comment From: lasselindqvist
I assumed that the
@Bean
public ServletRegistrationBean<DispatcherServlet> defaultDispatcherRegistration() {
return new ServletRegistrationBean<>(loggingDispatcherServlet(), "/*");
}
would work as a default fallback for others and
@Bean
public ServletRegistrationBean<DispatcherServlet> webdavDispatcherRegistration() {
return new ServletRegistrationBean<>(loggingDispatcherServlet(), "/servlet/*");
}
just for that path since it is more specific. If this is not supported really, how would one go define one dispatcher for a subpath and then one common default one?
Comment From: mdeinum
The problem is not that you have 1 servlet mapped to two URLs but you have 2 servlets mapped to 1 url. That is not supported by the Servlet Specification. If 2 servlets map to the same (sub) path only 1 will be used, which one is up to the container. I would expect the last one to be registered in the container and that apparently has changed in either Spring Boot or Tomcat (by not overriding anymore but keep the first one registered for instance).
So /servlet/abc
is mapped to the LoggingDispatcherServlet
but as there is no controller for /abc
you get a 404
.
In the 2.7 version it went to the HelloServlet
and this is the difference between 2.7/3.0 I suspect that this is due to a difference in either Spring Boot for the order servlets are being registered (as there is no order this might even differ between runs) or in Tomcat on how it works with paths that are already taken.
All that being said it boils down to the fact that you have 2 servlets that are mapped to /servlet/*
which simply isn't supported by the Servlet Specification. How should the servlet container know which servlet to invoke for a path with /servlet/xyx
?
Comment From: lasselindqvist
I am not sure I agree fully. The example in section 12.2.2 (of Servlet 3.1 specification) there is an example of servlet with overriding path mappings and 12.1 has rules on which mapping is chosen if multiple match the path. /servlet/ and / are not the same url and the spec has a rule "the longest match determines the servlet selected" in the logic, and I thought it would be used here.
Comment From: mdeinum
I'm not talking about the /*
and /servlet/*
for the LoggingDispatcherServlet
overlapping. I'm referencing the fact that you have 2 servlets on /servlet/*
this is not supported by the Servlet Specification nor Servlet containers. Both HelloServlet
and LoggingDispatcherServlet
are mapped to /servlet/*
.
There can be only one mapped to /servlet/*
. The logic for registrering the servlet on the URL has changed in probably Tomcat (first it would override, now it leaves the first registered one) or Spring Boot detects the ServletFilterRegistrationBean
in a different order between 2.7 and 3.1 which leads to a different order of registration.
Nonetheless there can be only 1 Servlet per url-pattern
and not multiple.
Comment From: lasselindqvist
So in that case all non-dispatcher servlets should then reside in some subpath. For example LoggingDispatcherServlet could be mapped to /servlet, but the HelloServlet should be in /servlet/hello and no actual servlet could respond at plain /servlet.
Comment From: mdeinum
It doesn't matter if it is a DispatcherServlet
or not. There can be only one Servlet
mapped to a url-pattern
. The best match will win, if there are multiple servlets to the url-pattern
one of them will win. Which one depends on the container I suspect (although I would expect a warning/error from Tomcat in this case).
I did some small testing and it appears to be something in Tomcat that changed. If you explicitly define an order
on the ServletRegistrationBean
for Spring Boot 2.7 you get the last one that is registered on /servlet/*
when using Spring Boot 3.1 you get the first one that is registered on /servlet/*
.
Comment From: bclozel
Let's not discuss the subtleties of the Servlet spec and containers implementations here.
I've checked that servlet beans are registered in the same order in 2.7.x and 3.1.x. You can verify that by putting a break point in org.springframework.boot.web.servlet.ServletContextInitializerBeans#addServletContextInitializerBean
. This setup is still fragile since all beans are ordered at default and we are only relying on the parsing order of the configuration class, which is not guaranteed by the core container. Still, the ordered list remains the same in both Spring Boot 2.7.x and 3.1.x.
Adding another break point in org.apache.catalina.mapper.Mapper#internalMapWrapper
does show that the matcher ordering changed between Tomcat versions. Both servlet are listed as wildcardWrappers
, but in a different order. I replicated the same behavior change with Undertow.
I think that this setup is invalid anyway as @mdeinum highlighted - duplicate mappings should not be supported or at least the behavior is not guaranteed. I'm closing this issue as a result. @lasselindqvist feel free to take this to the Tomcat user mailing list if you'd like to get to the bottom of this (tracking the behavior change or getting a final answer on this aspect of the spec). I could not track this behavior change to a particular Framework or Boot issue.