Description
Background
Having multiple WebSecurityConfigurerAdapter
's which are ordered and each processes a limited set of paths is a very handy tool for creating a structured security configuration. The advantages are:
- May add a catch all that denies all access for any request not explicitly whitelisted by previous configs.
- With both a regular API with basic auth, internal API for UI which uses login via SAML2 and some parts of an app open, configuring it all in one adapter would be messy (if even possible) and therefore also error-prone.
We have had a setup with multiple adapters for a long time and it has worked very well. Spring does not disallow it and there are parts in the code which even suggests that this is an intended use (for example the property securityFilterChainBuilders
in singleton WebSecurity
which is a List
of builders and not a single object). The role of method HttpSecurity.requestMatchers
also encourages such use.
Bug
Nonetheless, parts of Spring Security does not take this into account. More specifically, the WebSecurity
singleton has a property named filterSecurityInterceptor
which is populated with the FilterSecurityInterceptor
from the LAST WebSecurityConfigurerAdapter
processed. This FilterSecurityInterceptor
is then added to the bean DefaultWebInvocationPrivilegeEvaluator
created via bean method privilegeEvaluator
in WebSecurityConfiguration
. This means that any use of property filterSecurityInterceptor
in singleton WebSecurity
, or any use of bean DefaultWebInvocationPrivilegeEvaluator
will only take the last processed WebSecurityConfigurerAdapter
into account, which does not seem correct. Luckily, it seems to me that the only use of property filterSecurityInterceptor
is to create bean DefaultWebInvocationPrivilegeAdapter
and the only use of this bean is in newly added filter ErrorPageSecurityFilter
, which is also where we can see this bug at play. Nonetheless, the design seems flawed.
Note that this bug is NOT any of the following:
ErrorPageSecurityFilter
inherits fromHttpFilter
and notFilter
causingClassDefNotFound
exception in some environments:MockMvc
does not excludeErrorPageSecurityFilter
, which should only be included forERROR
dispatches, from regular dispatches. This is due toMockFilterChain
mistakenly including this filter no matter the dispatch type whereas the realApplicationFilterChain
used by Spring will exclude it if the dispatch type is notERROR
:
... however, in the case of the problems with MockMvc
, the bug presented herein come into play as well (even though there is also a problem with MockMvc
and their MockFilterChain
not taking dispatcher type into account).
Reproduce
Reproduction of bug is shown with filter ErrorPageSecurityFilter
.
Download project https://github.com/fast-reflexes/spring-boot-bug/tree/filterSecurityInterceptor
Project involves a security setup like
@EnableWebSecurity(debug = true)
@Order(0)
class Config: WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http
.requestMatchers()
.antMatchers("/error/**", "/test")
.and()
.authorizeRequests()
.anyRequest().permitAll()
}
}
@EnableWebSecurity(debug = true)
@Order(1)
class ClosedConfig: WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http
.requestMatchers()
.antMatchers("/non-existing")
.and()
.authorizeRequests()
.anyRequest().hasRole("PRIVILEGED_USER")
}
}
@EnableWebSecurity(debug = true)
@Order(2)
class CatchAllConfig: WebSecurityConfigurerAdapter() {
override fun configure(http: HttpSecurity) {
http
.authorizeRequests()
.anyRequest().denyAll()
}
}
See bug in action
-
Start app with
./gradlew bootRun
-
Go to
http://localhost:8080/non-existing
. This endpoint is restricted so Spring will want to send an error with 403. A new error dispatch starts. -
Check console and verify that Spring Security filter chain accepts the request, but when the
ErrorPageSecurityFilter
processes it, the request is considered unauthorized, because theDefaultWebInvocationPrivilegeEvaluator
only considers the lastWebSecurityConfigurerAdapter
which is a catch-all that denies everything. Therefore, only a status code of 403 is sent and no error page. Nonetheless, the firstWebSecurityConfigurerAdapter
explicitly allows access to/error
to anyone:... 2021-11-26 09:52:42.860 DEBUG 68724 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Secured GET /error 2021-11-26 09:52:42.897 TRACE 68724 --- [nio-8080-exec-1] o.s.s.w.a.expression.WebExpressionVoter : Voted to deny authorization 2021-11-26 09:52:42.900 DEBUG 68724 --- [nio-8080-exec-1] a.DefaultWebInvocationPrivilegeEvaluator : filter invocation [/error] denied for AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=3B7CB0BABC6A8360268E10BF4DB0BE8F], Granted Authorities=[ROLE_ANONYMOUS]] ...
Fix bug by changing last WebSecurityConfigurerAdapter
to permit all
-
Change the last configuration to
permitAll()
instead ofdenyAll()
. -
Run the previous test again.
-
Verify that the an actual error page is sent and output in console shows that access to
/error
is now permitted by theDefaultWebInvocationPrivilegeEvaluator
:... 2021-11-26 10:54:51.356 DEBUG 69801 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Secured GET /error 2021-11-26 10:54:51.371 TRACE 69801 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : "ERROR" dispatch for GET "/error", parameters={}, headers={masked} in DispatcherServlet 'dispatcherServlet' 2021-11-26 10:54:51.378 TRACE 69801 --- [nio-8080-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : 2 matching mappings: [{ [/error], produces [text/html]}, { [/error]}] 2021-11-26 10:54:51.379 TRACE 69801 --- [nio-8080-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#errorHtml(HttpServletRequest, HttpServletResponse) 2021-11-26 10:54:51.392 TRACE 69801 --- [nio-8080-exec-1] o.s.web.method.HandlerMethod : Arguments: [SecurityContextHolderAwareRequestWrapper[ org.springframework.security.web.context.HttpSessionSecurityContextRepository$SaveToSessionRequestWrapper@7fb8f2b5], org.springframework.security.web.context.HttpSessionSecurityContextRepository$SaveToSessionResponseWrapper@14b5cd81] 2021-11-26 10:54:51.420 DEBUG 69801 --- [nio-8080-exec-1] o.s.w.s.v.ContentNegotiatingViewResolver : Selected 'text/html' given [text/html, text/html;q=0.8] 2021-11-26 10:54:51.420 TRACE 69801 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Rendering view [org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration$StaticView@1770d1b3] 2021-11-26 10:54:51.426 DEBUG 69801 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Exiting from "ERROR" dispatch, status 403, headers={masked} ...
Chain of events
- During initialization bean method
springFilterSecurityChain
is executed in fileWebSecurityConfiguration
. This method creates the Spring Security filter chain filter. - For each
WebSecurityConfigurerAdapter
given, itsinit
method is executed. TheHttpSecurity
object is fetched for each config and queued as a builder insecurityFilterChainBuilders
property of typeList
inWebSecurity
via methodaddSecurityFilterChainBuilder
. This method returns theWebSecurity
object itself. In the sameinit
call, theWebSecurity.postBuildAction
property is set to aRunnable
which adds theFilterSecurityInterceptor
of the current config as thefilterSecurityInterceptor
property of theWebSecurity
singleton itself. Since this method call sets propertypostBuildAction
inWebSecurity
, it overwrites the previousRunnable
that this property was set to earlier. ThepostBuildAction
Runnable
is not executed yet but the final assigned property is aRunnable
which involves theHttpSecurity
object of the lastWebSecurityConfigurerAdapter
processed. - After all builders are added to the
WebSecurity
List
propertysecurityFilterChainBuilders
with builders (and thepostBuildAction
is overwritten each time), each config is processed and built. In methodconfigure
ofAbstractInterceptUrlConfigurer
, aFilterSecurityInterceptor
is created for and attached to each configuration. - After all the configs are processed and built, the
postBuildAction
inWebSecurity
is executed and attaches theFilterSecurityInterceptor
of the last processed config as thefilterSecurityInterceptor
of the singletonWebSecurity
object. TheFilterSecurityInterceptor
's of the other configs are, via the overwrittenpostBuildAction
Runnable
(and the fact that thesecurityInterceptor
of theWebSecurity
class is a single object and not a list of objects) ignored. - In file
WebSecurityConfiguration
, bean methodprivilegeEvaluator
is executed, constructing aWebInvocationPrivilegeEvaluator
bean in the shape of aDefaultWebInvocationPrivilegeEvaluator
which is constructed from methodgetPrivilegeEvaluator
in fileWebSecurity
. This method uses thefilterSecurityIinterceptor
set inWebSecurity
, which corresponds to theFilterSecurityInterceptor
of the last processedWebSecurityConfigurerAdapter
. - Now, in the reproductions you saw in the logs that Spring Security processes the request and then dispatches an errors message with path
/error
. You will see a new invocation in Spring Security with this requested path, and since we have a config that matches and allows the/error
path, Spring Security will allow it. TheApplicationFilterChain
will then call the newErrorPageSecurityFilter
, because it is anERROR
dispatch. - In
ErrorPageSecurityFilter,
the methoddoFilter
will execute, which will retrieve theWebInvocationPrivilegeEvaluator
bean, which is the bean from step 5. Once retrieved, this bean will use the available authentication along with itsFilterSecurityInterceptor
, from the last processedWebSecurityConfigurerAdapter
, to retrieve meta data security expressions and determine whether access can be given. If access is given, the initial dispatch is allowed, rendering a full error page. If access is denied, theErrorPageSecurityFilter
will send a response containing the intended http status code for the dispatch, but with nothing more. - Now consider the security setup above where
/error
paths are allowed to everyone and with a last catch-all security config which denies all. In such a setup, the security filter chain will allow access to the error page, but theErrorPageSecurityFilter
will, erroneously, deny access to the error page even though the configuration indicates that it should be allowed.
Problems
DefaultWebInvocationPrivilegeEvaluator
'sisAllowed
method should not be used since it doesn't make use of the full security config specified. Luckily, it seems this method is only used by theErrorPageSecurityFilter
.WebSecurity
's propertyfilterSecurityInterceptor
should not be used either since it ignores part of the security config. Seems it is only used to create theWebInvocationPrivilegeEvaluator
bean and since itsisAllowed
method is used only byErrorPageSecurityFilter
, its impact seems contained. Even if this problem is solved, I don't see how the concerned functionality of the given classes can continue to exist in their current form for any future use since they clearly don't capture the full given security config given to Spring.- If this behaviour is intended, it should be clearly advised against multiple
WebSecurityConfigurerAdapter
's and an exception should be thrown if multiple of these are present. Since I think multipleWebSecurityConfigurerAdapter
's have a real use-case, I suggest this bug should be fixed instead.
The problem is not per se, very big in the prescribed case, but the logic is flawed and if more components use the filterSecurityInterceptor
in WebSecurity
or the DefaultWebInvocationPrivilegeEvaluator
in the future, it could be worse. Therefore, it is important to either fix this bug or force users to only use one WebSecurityConfigurerAdapter
(not recommended imho).
Expected behaviour
Expected behaviour is that all use of property filterSecurityInterceptor
in WebSecurity
singleton, and all use of bean DefaultWebInvocationPrivilegeEvaluator
should take the entire security config into account, not only the part coming from the last processed WebSecurityConfigurerAdapter
. Alternatively, Spring should reject multiple WebSecurityConfigurerAdapter
's. Since the latter seems counterintuitive and there is a good usecase for multiple WebSecurityConfigurerAdapter
s, I'd rather see this as a bug and that the relevant parts of WebSecurity
and DefaultWebInvocationPrivilegeEvaluator
be rewritten.
To conclude, in this specific case, the entire error page should be allowed by ErrorPageSecurityFilter
in BOTH cases, even when the last WebSecurityConfigurerAdapter
is a catch-all which denies all access simply because a previous one explicitly allowed access to the /error
path to everyone.
A ticket has been filed in Spring Boot repo to to alert users of this behaviour in ErrorPageSecurityFilter
: spring-projects/spring-boot#28818