-
Notifications
You must be signed in to change notification settings - Fork 6.2k
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:
ErrorPageSecurityFilterinherits fromHttpFilterand notFiltercausingClassDefNotFoundexception in some environments:MockMvcdoes not excludeErrorPageSecurityFilter, which should only be included forERRORdispatches, from regular dispatches. This is due toMockFilterChainmistakenly including this filter no matter the dispatch type whereas the realApplicationFilterChainused 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
ErrorPageSecurityFilterprocesses it, the request is considered unauthorized, because theDefaultWebInvocationPrivilegeEvaluatoronly considers the lastWebSecurityConfigurerAdapterwhich is a catch-all that denies everything. Therefore, only a status code of 403 is sent and no error page. Nonetheless, the firstWebSecurityConfigurerAdapterexplicitly allows access to/errorto 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
/erroris 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
springFilterSecurityChainis executed in fileWebSecurityConfiguration. This method creates the Spring Security filter chain filter. - For each
WebSecurityConfigurerAdaptergiven, itsinitmethod is executed. TheHttpSecurityobject is fetched for each config and queued as a builder insecurityFilterChainBuildersproperty of typeListinWebSecurityvia methodaddSecurityFilterChainBuilder. This method returns theWebSecurityobject itself. In the sameinitcall, theWebSecurity.postBuildActionproperty is set to aRunnablewhich adds theFilterSecurityInterceptorof the current config as thefilterSecurityInterceptorproperty of theWebSecuritysingleton itself. Since this method call sets propertypostBuildActioninWebSecurity, it overwrites the previousRunnablethat this property was set to earlier. ThepostBuildActionRunnableis not executed yet but the final assigned property is aRunnablewhich involves theHttpSecurityobject of the lastWebSecurityConfigurerAdapterprocessed. - After all builders are added to the
WebSecurityListpropertysecurityFilterChainBuilderswith builders (and thepostBuildActionis overwritten each time), each config is processed and built. In methodconfigureofAbstractInterceptUrlConfigurer, aFilterSecurityInterceptoris created for and attached to each configuration. - After all the configs are processed and built, the
postBuildActioninWebSecurityis executed and attaches theFilterSecurityInterceptorof the last processed config as thefilterSecurityInterceptorof the singletonWebSecurityobject. TheFilterSecurityInterceptor's of the other configs are, via the overwrittenpostBuildActionRunnable(and the fact that thesecurityInterceptorof theWebSecurityclass is a single object and not a list of objects) ignored. - In file
WebSecurityConfiguration, bean methodprivilegeEvaluatoris executed, constructing aWebInvocationPrivilegeEvaluatorbean in the shape of aDefaultWebInvocationPrivilegeEvaluatorwhich is constructed from methodgetPrivilegeEvaluatorin fileWebSecurity. This method uses thefilterSecurityIinterceptorset inWebSecurity, which corresponds to theFilterSecurityInterceptorof 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/errorpath, Spring Security will allow it. TheApplicationFilterChainwill then call the newErrorPageSecurityFilter, because it is anERRORdispatch. - In
ErrorPageSecurityFilter,the methoddoFilterwill execute, which will retrieve theWebInvocationPrivilegeEvaluatorbean, 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, theErrorPageSecurityFilterwill send a response containing the intended http status code for the dispatch, but with nothing more. - Now consider the security setup above where
/errorpaths 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 theErrorPageSecurityFilterwill, erroneously, deny access to the error page even though the configuration indicates that it should be allowed.
Problems
DefaultWebInvocationPrivilegeEvaluator'sisAllowedmethod 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 propertyfilterSecurityInterceptorshould not be used either since it ignores part of the security config. Seems it is only used to create theWebInvocationPrivilegeEvaluatorbean and since itsisAllowedmethod 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 WebSecurityConfigurerAdapters, 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