spring-security
spring-security copied to clipboard
Default Cache-Control/Expires/Pragma headers are being added in async response with custom Cache-Control header value
Describe the bug
Setting up a basic async HTTP GET endpoint where the returned response is allowed to be cached by downstream clients (via the Cache-Control header) produces duplicate Cache-Control header values. Expires and Pragma headers are also being added.
To Reproduce
- Spring Boot 2.7.x project
- Basic application with
EnableWebSecurityannotation - Async HTTP GET endpoint where the
DeferredResultis aResponseEntitywith aCache-Controlheader value
Headers being returned in case of the async HTTP response:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 2
Content-Type: text/html;charset=UTF-8
Date: Mon, 13 Mar 2023 18:04:41 GMT
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Headers being returned in case of the sync HTTP response:
Cache-Control: max-age=0
Connection: keep-alive
Content-Length: 2
Content-Type: text/html;charset=UTF-8
Date: Mon, 13 Mar 2023 18:06:24 GMT
Keep-Alive: timeout=60
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Expected behavior
Setting Cache-Control/Expires/Pragma headers in async request processing context should be honored by the security header writer and it should not populate the HTTP response with the default headers in this case.
See the sample's sync endpoint for desired behavior.
Sample https://github.com/cmark/spring-security-async-cache-control
Any news on this? Are there any easy workarounds other than having to manually set the header for each async endpoint.
I am experiencing a similar problem on Spring Boot 3.2.2
I have a suspended function adding CacheControl headers via ResponseEntity (ImageService is returning a Mono<ByteArray>):
override suspend fun downloadImage(id: UUID): ResponseEntity<ByteArray> {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(30, java.util.concurrent.TimeUnit.DAYS))
.body(imageService.loadImage(id).awaitSingle())
}
Those headers are not in the response when CacheControlHeadersWriter adds its own headers, resulting in duplicate headers and frontend applications being confused.
My current workaround is to inject the HttpServletResponse, which is later passed to CacheControlHeadersWriter, and set headers manually:
override suspend fun downloadImage(id: UUID): ByteArray {
response.setHeader("Cache-Control", "max-age=2592000")
return imageService.loadImage(id).awaitSingle()
}
Please find a way so I can use idiomatic ResponseEntity objects with typesafe CacheControl.
I apologize for the delay in responding, @cmark. Thanks for reaching out and for providing a reproducer!
Expected behavior Setting
Cache-Control/Expires/Pragmaheaders in async request processing context should be honored by the security header writer and it should not populate the HTTP response with the default headers in this case.
The reference docs mention in Spring MVC Async Integration:
There is no automatic integration with a
DeferredResultthat is returned by controllers. This is becauseDeferredResultis processed by the users and, thus, there is no way of automatically integrating with it.
I believe this is the reason for this issue. The headers writer fires after the initial controller method returns, but is not able to wait for the DeferredResult#setResult method to be called.
One workaround you might consider is to create a separate filter chain for your async endpoint(s) that does not write the Cache-Control headers. For example:
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
@Order(1)
public SecurityFilterChain asyncSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/async")
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.headers((headers) -> headers
.cacheControl((cacheControl) -> cacheControl.disable())
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
// ...
}
Given that Spring Security does not integrate with DeferredResult, I'm not sure there is much we can do here since the headers are written as part of the filter chain, and Spring MVC is handling the result of your controller asynchronously.
Unfortunately, it appears this issue also exists when returning a Callable, and it may be for a similar reason (which is obviously not ideal). I will leave this issue open to see if anything can be done but I wanted to at least provide a workaround for you to try. If you're still around, please let me know if this helps you (or anyone else).
Hi @sjohnr,
Thank you for the detailed explanation and the workaround. In the meantime, we have solved the problem by handling the HTTP header writing ourselves (directly in the HTTPResponse), overriding any previously written HTTP header.
I will leave this issue open to see if anything can be done In the long run, it would be nice to have a built-in solution for this in Spring Security.
Thanks again, Mark