As asked on gitter I found an issue after upgrading to Spring Boot 2.6.0: running a @SpringBootTest
with @AutoConfigureMockMvc
a login page (not limited to) is no longer accessible after the upgrade. The same configuration that worked on Spring Boot 2.5.7 now triggers a 401. Tracing this lead me to the ErrorPageSecurityFilter
.
Since 2.6.0 the initial request gets granted, but the (mock) filter chain goes through the ErrorPageSecurityFilter
and denies in later.
I made a small example project to reproduce the issue: https://github.com/martinvisser/error-page-security-filter-issue.
For reasons I can't exactly remember I had multiple configuration extending from WebSecurityConfigurerAdapter
which worked in Spring Boot 2.5.7. Merging the two configurations into one fixed the issue for me, but it does still sound like unforeseen and unwanted behavior.
This worked in 2.5.7, but fails in 2.6.0:
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
internal class WebSecurityConfig : WebSecurityConfigurerAdapter() {
@Configuration(proxyBeanMethods = false)
@Order(1)
internal class FormWebSecurityConfigurerAdapter : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http.authorizeRequests {
it.anyRequest().permitAll()
}
}
}
}
This works in both though:
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
internal class WebSecurityConfig : WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http.authorizeRequests {
it.anyRequest().permitAll()
}
}
}
Comment From: martinvisser
Perhaps related to https://github.com/spring-projects/spring-boot/issues/26356 and/or https://github.com/spring-projects/spring-boot/issues/28741
Comment From: wilkinsona
Thanks for the sample, @martinvisser.
The cause of the problem is that ErrorPageSecurityFilter
is being called for a REQUEST
dispatch despite only being registered for ERROR
dispatches. This is a limitation of MockMvc that I had overlooked when working on the changes for #26356 with @mbhave. I've opened https://github.com/spring-projects/spring-framework/issues/27717 so that the Framework team can consider an enhancement to MockMvc that would allow us to register filters for particular dispatcher types. In the meantime, we can update Boot's ErrorPageSecurityFilter
to ignore non-ERROR
requests.
Comment From: wilkinsona
A workaround for this problem is to remove the error page security filter by adding the following bean to the configuration used in your application's tests:
@Bean
static BeanFactoryPostProcessor removeErrorSecurityFilter() {
return (beanFactory) ->
((DefaultListableBeanFactory)beanFactory).removeBeanDefinition("errorPageSecurityInterceptor");
}
Comment From: steklopod
I also have the same problem with 2.6.0
only:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
class SecurityConfig(private val userService: UserService) : WebSecurityConfigurerAdapter() {
companion object {
private val whitelist = arrayOf(
"/registration", "/registration/*", "/login", "/user/exists/*"
)
}
override fun configure(webSecurity: WebSecurity) { webSecurity.ignoring().antMatchers(*whitelist) }
override fun configure(auth: AuthenticationManagerBuilder) { auth.authenticationProvider(authProvider()) }
@Bean override fun authenticationManagerBean(): AuthenticationManager = super.authenticationManagerBean()
@Bean fun passwordEncoder(): PasswordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder()
@Bean fun authProvider (): DaoAuthenticationProvider = DaoAuthenticationProvider().apply { setUserDetailsService(userService); setPasswordEncoder(BCryptPasswordEncoder()) }
Result
java.lang.AssertionError: Status expected:<200> but was:<401>
Expected :200
Actual :401
My test:
@SpringBootTest
@AutoConfigureMockMvc
internal class LoginControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val userService: UserService,
) {
private val loginUrl = "/login"
private val email = """email@gmail.com"""
private val principal = """{ "email": "$email", "password": "test" }"""
@Test
fun `login and logout`() {
val result = mockMvc.perform(
post(loginUrl).contentType(APPLICATION_JSON)
.content(principal)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isOk)
}
Comment From: wilkinsona
@steklopod That does look like the same problem. If you haven't done so already, please try the workaround.
Comment From: steklopod
Thank you. It solved my problem. I just put it my @Configuration
file in backend/src/test/kotlin/config/AnyTestConfig.kt
:
Kotlin bean:
@Bean
fun removeErrorSecurityFilter(): BeanFactoryPostProcessor =
BeanFactoryPostProcessor { beanFactory: ConfigurableListableBeanFactory ->
(beanFactory as DefaultListableBeanFactory).removeBeanDefinition("errorPageSecurityInterceptor")
}
Comment From: fast-reflexes
I opened a similar ticket in Spring Security's Github repo: https://github.com/spring-projects/spring-security/issues/10544. I also use multiple configurations and test repo is available here: https://github.com/fast-reflexes/spring-boot-bug
So...
- Root to problem found ✅
- Long-term and short-term fixes underway ✅
- Reason to problem ❌
I like to understand what's going on in Spring and especially security-wise, but here I feel a little lost:
1. Finding the root of the problem
I have turned on debugging on all my configs and set logging.level.org=TRACE
and logging.level.com=TRACE
and log all output streams while running a single test which used to pass and now fails. The only place where I see EITHER ErrorPageSecurityFilter
or errorPageSecurityInterceptor
(the filter registration bean) connected to this failed test is in the stacktrace:
org.springframework.security.access.AccessDeniedException: Access is denied
at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73) ~[spring-security-core-5.6.0.jar:5.6.0]
at org.springframework.security.web.access.DefaultWebInvocationPrivilegeEvaluator.isAllowed(DefaultWebInvocationPrivilegeEvaluator.java:100) ~[spring-security-web-5.6.0.jar:5.6.0]
at org.springframework.security.web.access.DefaultWebInvocationPrivilegeEvaluator.isAllowed(DefaultWebInvocationPrivilegeEvaluator.java:67) ~[spring-security-web-5.6.0.jar:5.6.0]
at org.springframework.boot.web.servlet.filter.ErrorPageSecurityFilter.doFilter(ErrorPageSecurityFilter.java:58) ~[spring-boot-2.6.0.jar:2.6.0]
at javax.servlet.http.HttpFilter.doFilter(HttpFilter.java:57) ~[tomcat-embed-core-9.0.55.jar:4.0.FR]
....
Apart from that, the errorPageSecurityInterceptor
is mentioned ONCE in quite a load of output :)
Was is from this that you found that this filter was the culprit or did I miss something?
2. What does ErrorPageSecurityFilter
solve?
I've read the tickets about this problem but I still don't get it. So there used to be some discrepancy between how error messages were sent in connection to endpoints requiring authentication; if no credentials were given, you got a more juicy error message than when you supplied incorrect credentials. Seems a bit odd, I agree with that, but why did it take a new filter to solve it and exactly how does this filter solve it?
3. Role in the filter chain
As you understand, I did quite some debugging, but feel puzzled about the behaviour, even given this ErrorPageSecurityFilter
. I've understood that this filter is installed outside of Spring Security and that it runs among the filters. I guess then that it is not Spring Security who denies access?
This is my error message with multiple configs where the lowest-order config denies all access (catch-all):
2021-11-23 10:24:42.450 TRACE 82423 --- [ Test worker] o.s.security.web.FilterChainProxy : Invoking FilterSecurityInterceptor (12/12)
2021-11-23 10:24:42.450 TRACE 82423 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Did not re-authenticate UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_API_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_API_USER]] before authorizing
2021-11-23 10:24:42.450 TRACE 82423 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Authorizing filter invocation [GET /api/bogus] with attributes [hasAnyRole('ROLE_API_USER')]
2021-11-23 10:24:42.450 DEBUG 82423 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Authorized filter invocation [GET /api/bogus] with attributes [hasAnyRole('ROLE_API_USER')]
2021-11-23 10:24:42.450 TRACE 82423 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Did not switch RunAs authentication since RunAsManager returned null
2021-11-23 10:24:42.450 DEBUG 82423 --- [ Test worker] o.s.security.web.FilterChainProxy : Secured GET /api/bogus
2021-11-23 10:24:42.450 TRACE 82423 --- [ Test worker] o.s.s.w.a.expression.WebExpressionVoter : Voted to deny authorization
2021-11-23 10:24:42.455 DEBUG 82423 --- [ Test worker] a.DefaultWebInvocationPrivilegeEvaluator : filter invocation [/api/bogus] denied for UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_API_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_API_USER]]
org.springframework.security.access.AccessDeniedException: Access is denied
at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73) ~[spring-security-core-5.6.0.jar:5.6.0]
...
2021-11-23 10:24:42.456 TRACE 82423 --- [ Test worker] o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
Without the last deny-all config, the above is turned into:
2021-11-23 10:28:24.524 TRACE 82954 --- [ Test worker] o.s.security.web.FilterChainProxy : Invoking FilterSecurityInterceptor (12/12)
2021-11-23 10:28:24.524 TRACE 82954 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Did not re-authenticate UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_API_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[ROLE_API_USER]] before authorizing
2021-11-23 10:28:24.524 TRACE 82954 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Authorizing filter invocation [GET /api/bogus] with attributes [hasAnyRole('ROLE_API_USER')]
2021-11-23 10:28:24.524 DEBUG 82954 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Authorized filter invocation [GET /api/bogus] with attributes [hasAnyRole('ROLE_API_USER')]
2021-11-23 10:28:24.525 TRACE 82954 --- [ Test worker] o.s.s.w.a.i.FilterSecurityInterceptor : Did not switch RunAs authentication since RunAsManager returned null
2021-11-23 10:28:24.525 DEBUG 82954 --- [ Test worker] o.s.security.web.FilterChainProxy : Secured GET /api/bogus
2021-11-23 10:28:24.525 TRACE 82954 --- [ Test worker] o.s.t.web.servlet.TestDispatcherServlet : GET "/api/bogus", parameters={}, headers={masked} in DispatcherServlet ''
2021-11-23 10:28:24.526 TRACE 82954 --- [ Test worker] o.s.b.f.s.DefaultListableBeanFactory : Returning cached instance of singleton bean 'exampleEndpoint'
2021-11-23 10:28:24.526 TRACE 82954 --- [ Test worker] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.examplespringboot.ExampleEndpoint#bogus()
2021-11-23 10:28:24.526 TRACE 82954 --- [ Test worker] o.s.web.method.HandlerMethod : Arguments: []
2021-11-23 10:28:24.527 DEBUG 82954 --- [ Test worker] m.m.a.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain, */*, text/plain, */*, application/json, application/*+json, application/json, application/*+json]
2021-11-23 10:28:24.527 TRACE 82954 --- [ Test worker] m.m.a.RequestResponseBodyMethodProcessor : Writing ["bogus"]
2021-11-23 10:28:24.528 TRACE 82954 --- [ Test worker] o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
So it seems to me that in both cases Spring Security says YES, but in the first case, there is some problem when the ErrorPageSecurityFilter
processes the request, but exactly what is the problem and why does it disappear once I remove the deny-all config? It seems ErrorPageSecurityFilter
invokes DefaultWebInvocationPrivilegeEvaluator
. This in turn seems to somehow call WebExpressionVoter
(via some other class) which turns our request down... Why? And what is this class used for otherwise?
Note that I DO have an entry for /error
path in my security config and it allows access to all.
I would LOVE to understand this better so if you have time, feel free to fill me in :)
Comment From: fast-reflexes
I investigated the matter myself and found an additional bug. Explanations to the above and info about this bug can be found at https://github.com/spring-projects/spring-boot/issues/28818