Skip to content

Commit f0a6128

Browse files
committed
Add spring.web.resources.cache.use-last-modified
Prior to this commit, packaging a Spring Boot application as a container image with Cloud Native Buildpacks could result in unwanted browser caching behavior, with "Last-Modified" HTTP response headers pointing to dates in the far past. This is due to CNB resetting the last-modified date metadata for static files (for build reproducibility and container layer caching) and Spring static resource handling relying on that information when serving static resources. This commit introduces a new configuration property `spring.web.resources.cache.use-last-modified` that can be used to disable this behavior in Spring if the application is meant to run as a container image built by CNB. The default value for this property remains `true` since this remains the default value in Spring Framework and using that information in other deployment models is a perfectly valid use case. Fixes gh-24099
1 parent 673a5ac commit f0a6128

File tree

6 files changed

+59
-23
lines changed

6 files changed

+59
-23
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/WebProperties.java

+14
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,12 @@ public static class Cache {
361361
*/
362362
private final Cachecontrol cachecontrol = new Cachecontrol();
363363

364+
/**
365+
* Whether we should use the "lastModified" metadata of the files in HTTP
366+
* caching headers. Enabled by default.
367+
*/
368+
private boolean useLastModified = true;
369+
364370
public Duration getPeriod() {
365371
return this.period;
366372
}
@@ -374,6 +380,14 @@ public Cachecontrol getCachecontrol() {
374380
return this.cachecontrol;
375381
}
376382

383+
public boolean isUseLastModified() {
384+
return this.useLastModified;
385+
}
386+
387+
public void setUseLastModified(boolean useLastModified) {
388+
this.useLastModified = useLastModified;
389+
}
390+
377391
private boolean hasBeenCustomized() {
378392
return this.customized || getCachecontrol().hasBeenCustomized();
379393
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java

+1
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ private void configureResourceCaching(ResourceHandlerRegistration registration)
205205
cacheControl.setMaxAge(cachePeriod);
206206
}
207207
registration.setCacheControl(cacheControl.toHttpCacheControl());
208+
registration.setUseLastModified(this.resourceProperties.getCache().isUseLastModified());
208209
}
209210

210211
@Override

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java

+4-2
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,15 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
329329
if (!registry.hasMappingForPattern("/webjars/**")) {
330330
customizeResourceHandlerRegistration(registry.addResourceHandler("/webjars/**")
331331
.addResourceLocations("classpath:/META-INF/resources/webjars/")
332-
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
332+
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl)
333+
.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()));
333334
}
334335
String staticPathPattern = this.mvcProperties.getStaticPathPattern();
335336
if (!registry.hasMappingForPattern(staticPathPattern)) {
336337
customizeResourceHandlerRegistration(registry.addResourceHandler(staticPathPattern)
337338
.addResourceLocations(getResourceLocations(this.resourceProperties.getStaticLocations()))
338-
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl));
339+
.setCachePeriod(getSeconds(cachePeriod)).setCacheControl(cacheControl)
340+
.setUseLastModified(this.resourceProperties.getCache().isUseLastModified()));
339341
}
340342
}
341343

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfigurationTests.java

+13
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,19 @@ void cacheControl(String prefix) {
449449
Assertions.setExtractBareNamePropertyMethods(true);
450450
}
451451

452+
@Test
453+
void useLastModified() {
454+
this.contextRunner.withPropertyValues("spring.web.resources.cache.use-last-modified=false").run((context) -> {
455+
Map<PathPattern, Object> handlerMap = getHandlerMap(context);
456+
assertThat(handlerMap).hasSize(2);
457+
for (Object handler : handlerMap.values()) {
458+
if (handler instanceof ResourceWebHandler) {
459+
assertThat(((ResourceWebHandler) handler).isUseLastModified()).isFalse();
460+
}
461+
}
462+
});
463+
}
464+
452465
@Test
453466
void customPrinterAndParserShouldBeRegisteredAsConverters() {
454467
this.contextRunner.withUserConfiguration(ParserConfiguration.class, PrinterConfiguration.class)

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java

+24-20
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import java.util.List;
2929
import java.util.Locale;
3030
import java.util.Map;
31-
import java.util.Map.Entry;
3231
import java.util.concurrent.Executor;
3332
import java.util.concurrent.TimeUnit;
3433
import java.util.function.Consumer;
@@ -754,26 +753,25 @@ void httpMessageConverterThatUsesConversionServiceDoesNotCreateACycle() {
754753
@ParameterizedTest
755754
@ValueSource(strings = { "spring.resources.", "spring.web.resources." })
756755
void cachePeriod(String prefix) {
757-
this.contextRunner.withPropertyValues(prefix + "cache.period:5").run(this::assertCachePeriod);
758-
}
759-
760-
private void assertCachePeriod(AssertableWebApplicationContext context) {
761-
Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
762-
assertThat(handlerMap).hasSize(2);
763-
for (Entry<String, Object> entry : handlerMap.entrySet()) {
764-
Object handler = entry.getValue();
765-
if (handler instanceof ResourceHttpRequestHandler) {
766-
assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(5);
767-
assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).isNull();
768-
}
769-
}
756+
this.contextRunner.withPropertyValues(prefix + "cache.period:5").run((context) -> {
757+
assertResourceHttpRequestHandler((context), (handler) -> {
758+
assertThat(handler.getCacheSeconds()).isEqualTo(5);
759+
assertThat(handler.getCacheControl()).isNull();
760+
});
761+
});
770762
}
771763

772764
@ParameterizedTest
773765
@ValueSource(strings = { "spring.resources.", "spring.web.resources." })
774766
void cacheControl(String prefix) {
775-
this.contextRunner.withPropertyValues(prefix + "cache.cachecontrol.max-age:5",
776-
prefix + "cache.cachecontrol.proxy-revalidate:true").run(this::assertCacheControl);
767+
this.contextRunner
768+
.withPropertyValues(prefix + "cache.cachecontrol.max-age:5",
769+
prefix + "cache.cachecontrol.proxy-revalidate:true")
770+
.run((context) -> assertResourceHttpRequestHandler(context, (handler) -> {
771+
assertThat(handler.getCacheSeconds()).isEqualTo(-1);
772+
assertThat(handler.getCacheControl()).usingRecursiveComparison()
773+
.isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate());
774+
}));
777775
}
778776

779777
@Test
@@ -939,14 +937,20 @@ void urlPathHelperDoesNotUseFullPathWithAdditionalUntypedDispatcherServlet() {
939937
});
940938
}
941939

942-
private void assertCacheControl(AssertableWebApplicationContext context) {
940+
@Test
941+
void lastModifiedNotUsedIfDisabled() {
942+
this.contextRunner.withPropertyValues("spring.web.resources.cache.use-last-modified=false")
943+
.run((context) -> assertResourceHttpRequestHandler(context,
944+
(handler) -> assertThat(handler.isUseLastModified()).isFalse()));
945+
}
946+
947+
private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context,
948+
Consumer<ResourceHttpRequestHandler> handlerConsumer) {
943949
Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
944950
assertThat(handlerMap).hasSize(2);
945951
for (Object handler : handlerMap.keySet()) {
946952
if (handler instanceof ResourceHttpRequestHandler) {
947-
assertThat(((ResourceHttpRequestHandler) handler).getCacheSeconds()).isEqualTo(-1);
948-
assertThat(((ResourceHttpRequestHandler) handler).getCacheControl()).usingRecursiveComparison()
949-
.isEqualTo(CacheControl.maxAge(5, TimeUnit.SECONDS).proxyRevalidate());
953+
handlerConsumer.accept((ResourceHttpRequestHandler) handler);
950954
}
951955
}
952956
}

spring-boot-project/spring-boot-docs/src/docs/asciidoc/spring-boot-features.adoc

+3-1
Original file line numberDiff line numberDiff line change
@@ -8925,7 +8925,9 @@ This means you can just type a single command and quickly get a sensible image i
89258925

89268926
Refer to the individual plugin documentation on how to use buildpacks with {spring-boot-maven-plugin-docs}#build-image[Maven] and {spring-boot-gradle-plugin-docs}#build-image[Gradle].
89278927

8928-
8928+
NOTE: In order to achieve reproducible builds and container image caching, Buildpacks can manipulate the application resources metadata (such as the file "last modified" information).
8929+
You should ensure that your application does not rely on that metadata at runtime.
8930+
Spring Boot can use that information when serving static resources, but this can be disabled with configprop:spring.web.resources.cache.use-last-modified[]
89298931

89308932
[[boot-features-whats-next]]
89318933
== What to Read Next

0 commit comments

Comments
 (0)