From 3a91d90c28ec2012cbda2f7fa39910a1ea4cc31f Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 30 Apr 2026 15:17:45 +0200 Subject: [PATCH 1/4] Resolve URL path for versioned webjar directories Prior to this commit, the `resolveUrlPath` implementation for the `LiteWebJarsResourceResolver` would always delegate to the resource chain once the versioned webjar folder has been resolved. While this aligns with the `ResourceResolver` contract and the fact that the resource chain does not resolve directories, here the WebJar locator does support such use cases and we shouldn't get in the way here. This commit falls back to the resolved versioned WebJar path if no resource could be resolved and the path ends with "/". Fixes gh-36726 --- framework-platform/framework-platform.gradle | 1 - spring-webflux/spring-webflux.gradle | 2 +- .../resource/LiteWebJarsResourceResolver.java | 3 +- .../LiteWebJarsResourceResolverTests.java | 28 +++++++++++++++---- spring-webmvc/spring-webmvc.gradle | 2 +- .../resource/LiteWebJarsResourceResolver.java | 5 +++- .../LiteWebJarsResourceResolverTests.java | 28 +++++++++++++++---- 7 files changed, 52 insertions(+), 17 deletions(-) diff --git a/framework-platform/framework-platform.gradle b/framework-platform/framework-platform.gradle index 986fed60b25..8fce3ed3215 100644 --- a/framework-platform/framework-platform.gradle +++ b/framework-platform/framework-platform.gradle @@ -138,7 +138,6 @@ dependencies { api("org.seleniumhq.selenium:selenium-java:4.41.0") api("org.skyscreamer:jsonassert:1.5.3") api("org.testng:testng:7.12.0") - api("org.webjars:underscorejs:1.8.3") api("org.webjars:webjars-locator-lite:1.1.0") api("org.xmlunit:xmlunit-assertj:2.10.4") api("org.xmlunit:xmlunit-matchers:2.10.4") diff --git a/spring-webflux/spring-webflux.gradle b/spring-webflux/spring-webflux.gradle index 16c9be3a271..8de09951ee5 100644 --- a/spring-webflux/spring-webflux.gradle +++ b/spring-webflux/spring-webflux.gradle @@ -61,7 +61,7 @@ dependencies { testRuntimeOnly("org.glassfish:jakarta.el") testRuntimeOnly("org.jruby:jruby") testRuntimeOnly("org.python:jython-standalone") - testRuntimeOnly("org.webjars:underscorejs") + testRuntimeOnly("org.webjars:momentjs:2.29.4") } test { diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java index 6983d2fddfa..3fd7b40c402 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolver.java @@ -90,7 +90,8 @@ public class LiteWebJarsResourceResolver extends AbstractResourceResolver { .switchIfEmpty(Mono.defer(() -> { String webJarResourcePath = findWebJarResourcePath(resourceUrlPath); if (webJarResourcePath != null) { - return chain.resolveUrlPath(webJarResourcePath, locations); + Mono fallback = (webJarResourcePath.endsWith("/")) ? Mono.just(webJarResourcePath) : Mono.empty(); + return chain.resolveUrlPath(webJarResourcePath, locations).switchIfEmpty(fallback); } else { return Mono.empty(); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolverTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolverTests.java index e7a5ce16cff..fe8d35f87c9 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolverTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/resource/LiteWebJarsResourceResolverTests.java @@ -29,6 +29,8 @@ import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRe import org.springframework.web.testfixture.server.MockServerWebExchange; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -80,8 +82,8 @@ class LiteWebJarsResourceResolverTests { @Test void resolveUrlWebJarResource() { - String file = "underscorejs/underscore.js"; - String expected = "underscorejs/1.8.3/underscore.js"; + String file = "momentjs/momentjs.js"; + String expected = "momentjs/2.29.4/momentjs.js"; given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty()); given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(Mono.just(expected)); @@ -92,10 +94,24 @@ class LiteWebJarsResourceResolverTests { verify(this.chain, times(1)).resolveUrlPath(expected, this.locations); } + @Test + void resolveUrlWebJarDirectory() { + String folder = "momentjs/locale/"; + String expected = "momentjs/2.29.4/locale/"; + given(this.chain.resolveUrlPath(folder, this.locations)).willReturn(Mono.empty()); + given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(Mono.empty()); + + String actual = this.resolver.resolveUrlPath(folder, this.locations, this.chain).block(TIMEOUT); + + assertThat(actual).isEqualTo(expected); + verify(this.chain, times(1)).resolveUrlPath(folder, this.locations); + verify(this.chain, times(1)).resolveUrlPath(expected, this.locations); + } + @Test void resolveUrlWebJarResourceNotFound() { - String file = "something/something.js"; - given(this.chain.resolveUrlPath(file, this.locations)).willReturn(Mono.empty()); + String file = "momentjs/locale/unknown.js"; + given(this.chain.resolveUrlPath(anyString(), eq(this.locations))).willReturn(Mono.empty()); String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain).block(TIMEOUT); @@ -134,11 +150,11 @@ class LiteWebJarsResourceResolverTests { @Test void resolveResourceWebJar() { - String file = "underscorejs/underscore.js"; + String file = "momentjs/momentjs.js"; given(this.chain.resolveResource(this.exchange, file, this.locations)).willReturn(Mono.empty()); Resource expected = mock(); - String expectedPath = "underscorejs/1.8.3/underscore.js"; + String expectedPath = "momentjs/2.29.4/momentjs.js"; given(this.chain.resolveResource(this.exchange, expectedPath, this.locations)) .willReturn(Mono.just(expected)); diff --git a/spring-webmvc/spring-webmvc.gradle b/spring-webmvc/spring-webmvc.gradle index 760530e3c87..194278ad7c0 100644 --- a/spring-webmvc/spring-webmvc.gradle +++ b/spring-webmvc/spring-webmvc.gradle @@ -80,5 +80,5 @@ dependencies { testRuntimeOnly("org.glassfish:jakarta.el") testRuntimeOnly("org.jruby:jruby") testRuntimeOnly("org.python:jython-standalone") - testRuntimeOnly("org.webjars:underscorejs") + testRuntimeOnly("org.webjars:momentjs:2.29.4") } diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolver.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolver.java index 1486063b0e2..358927ab1e7 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolver.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolver.java @@ -88,7 +88,10 @@ public class LiteWebJarsResourceResolver extends AbstractResourceResolver { if (path == null) { String webJarResourcePath = findWebJarResourcePath(resourceUrlPath); if (webJarResourcePath != null) { - return chain.resolveUrlPath(webJarResourcePath, locations); + path = chain.resolveUrlPath(webJarResourcePath, locations); + if (path == null && webJarResourcePath.endsWith("/")) { + path = webJarResourcePath; + } } } return path; diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolverTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolverTests.java index 101c0630692..724ab79d87a 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolverTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/LiteWebJarsResourceResolverTests.java @@ -26,6 +26,8 @@ import org.springframework.core.io.Resource; import org.springframework.web.testfixture.servlet.MockHttpServletRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -74,8 +76,8 @@ class LiteWebJarsResourceResolverTests { @Test void resolveUrlWebJarResource() { - String file = "underscorejs/underscore.js"; - String expected = "underscorejs/1.8.3/underscore.js"; + String file = "momentjs/momentjs.js"; + String expected = "momentjs/2.29.4/momentjs.js"; given(this.chain.resolveUrlPath(file, this.locations)).willReturn(null); given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(expected); @@ -86,10 +88,24 @@ class LiteWebJarsResourceResolverTests { verify(this.chain, times(1)).resolveUrlPath(expected, this.locations); } + @Test + void resolveUrlWebJarDirectory() { + String folder = "momentjs/locale/"; + String expected = "momentjs/2.29.4/locale/"; + given(this.chain.resolveUrlPath(folder, this.locations)).willReturn(null); + given(this.chain.resolveUrlPath(expected, this.locations)).willReturn(null); + + String actual = this.resolver.resolveUrlPath(folder, this.locations, this.chain); + + assertThat(actual).isEqualTo(expected); + verify(this.chain, times(1)).resolveUrlPath(folder, this.locations); + verify(this.chain, times(1)).resolveUrlPath(expected, this.locations); + } + @Test void resolveUrlWebJarResourceNotFound() { - String file = "something/something.js"; - given(this.chain.resolveUrlPath(file, this.locations)).willReturn(null); + String file = "momentjs/locale/unknown.js"; + given(this.chain.resolveUrlPath(anyString(), eq(this.locations))).willReturn(null); String actual = this.resolver.resolveUrlPath(file, this.locations, this.chain); @@ -125,8 +141,8 @@ class LiteWebJarsResourceResolverTests { @Test void resolveResourceWebJar() { Resource expected = mock(); - String file = "underscorejs/underscore.js"; - String expectedPath = "underscorejs/1.8.3/underscore.js"; + String file = "momentjs/momentjs.js"; + String expectedPath = "momentjs/2.29.4/momentjs.js"; given(this.chain.resolveResource(this.request, expectedPath, this.locations)).willReturn(expected); Resource actual = this.resolver.resolveResource(this.request, file, this.locations, this.chain); From 41cd6879bde41abe7856fcad94c55e6413c3b019 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 30 Apr 2026 16:22:19 +0200 Subject: [PATCH 2/4] Fix parsing failure for MIME types with quoted pairs Prior to this commit, MIME types with parameter values that contain a quoted pair would sometimes fail and parse an incomplete parameter value. This commit ensures that the quoted section of the parameter value is correctly handled. Fixes gh-36730 --- .../java/org/springframework/util/MimeTypeUtils.java | 2 +- .../java/org/springframework/util/MimeTypeTests.java | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/util/MimeTypeUtils.java b/spring-core/src/main/java/org/springframework/util/MimeTypeUtils.java index 1b9b668ba0d..24add7f4f3d 100644 --- a/spring-core/src/main/java/org/springframework/util/MimeTypeUtils.java +++ b/spring-core/src/main/java/org/springframework/util/MimeTypeUtils.java @@ -238,7 +238,7 @@ public abstract class MimeTypeUtils { break; } } - else if (ch == '"') { + else if (ch == '"' && mimeType.charAt(nextIndex - 1) != '\\') { quoted = !quoted; } nextIndex++; diff --git a/spring-core/src/test/java/org/springframework/util/MimeTypeTests.java b/spring-core/src/test/java/org/springframework/util/MimeTypeTests.java index b1998ca0795..6346b8ec540 100644 --- a/spring-core/src/test/java/org/springframework/util/MimeTypeTests.java +++ b/spring-core/src/test/java/org/springframework/util/MimeTypeTests.java @@ -98,7 +98,7 @@ class MimeTypeTests { } @Test - void parseQuotedSeparator() { + void parseQuotedParameterValue() { String s = "application/xop+xml;charset=utf-8;type=\"application/soap+xml;action=\\\"https://x.y.z\\\"\""; MimeType mimeType = MimeType.valueOf(s); assertThat(mimeType.getType()).as("Invalid type").isEqualTo("application"); @@ -107,6 +107,15 @@ class MimeTypeTests { assertThat(mimeType.getParameter("type")).isEqualTo("\"application/soap+xml;action=\\\"https://x.y.z\\\"\""); } + @Test + void parseParameterWithQuotedPair() { + String s = "text/plain;twelve=\"1\\\"2\""; + MimeType mimeType = MimeType.valueOf(s); + assertThat(mimeType.getType()).as("Invalid type").isEqualTo("text"); + assertThat(mimeType.getSubtype()).as("Invalid subtype").isEqualTo("plain"); + assertThat(mimeType.getParameter("twelve")).isEqualTo("\"1\\\"2\""); + } + @Test void withConversionService() { ConversionService conversionService = new DefaultConversionService(); From bff98999056fc29b573ff47ad2433462eb52833c Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 1 May 2026 14:17:20 +0100 Subject: [PATCH 3/4] Switch to JdkIdGenerator in AbstractWebSocketSession Closes gh-36740 --- .../web/socket/adapter/AbstractWebSocketSession.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSession.java b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSession.java index 0b638543826..46f4fc10421 100644 --- a/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSession.java +++ b/spring-websocket/src/main/java/org/springframework/web/socket/adapter/AbstractWebSocketSession.java @@ -24,9 +24,9 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; -import org.springframework.util.AlternativeJdkIdGenerator; import org.springframework.util.Assert; import org.springframework.util.IdGenerator; +import org.springframework.util.JdkIdGenerator; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.PingMessage; @@ -44,7 +44,7 @@ import org.springframework.web.socket.WebSocketSession; */ public abstract class AbstractWebSocketSession implements NativeWebSocketSession { - protected static final IdGenerator idGenerator = new AlternativeJdkIdGenerator(); + protected static final IdGenerator idGenerator = new JdkIdGenerator(); protected static final Log logger = LogFactory.getLog(NativeWebSocketSession.class); From d72da90d3a562632e2b565113813f7b4a31f8717 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Fri, 1 May 2026 15:12:03 +0100 Subject: [PATCH 4/4] Avoid race in InMemoryWebSession Closes gh-36742 --- .../web/server/WebSession.java | 28 ++++---- .../session/InMemoryWebSessionStore.java | 67 ++++++++++++------- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/server/WebSession.java b/spring-web/src/main/java/org/springframework/web/server/WebSession.java index a1c823393f9..3142dbf4873 100644 --- a/spring-web/src/main/java/org/springframework/web/server/WebSession.java +++ b/spring-web/src/main/java/org/springframework/web/server/WebSession.java @@ -100,20 +100,6 @@ public interface WebSession { */ boolean isStarted(); - /** - * Generate a new id for the session and update the underlying session - * storage to reflect the new id. After a successful call {@link #getId()} - * reflects the new session id. - * @return completion notification (success or error) - */ - Mono changeSessionId(); - - /** - * Invalidate the current session and clear session storage. - * @return completion notification (success or error) - */ - Mono invalidate(); - /** * Save the session through the {@code WebSessionStore} as follows: *
    @@ -131,6 +117,20 @@ public interface WebSession { */ Mono save(); + /** + * Generate a new id for the session and update the underlying session + * storage to reflect the new id. After a successful call {@link #getId()} + * reflects the new session id. + * @return completion notification (success or error) + */ + Mono changeSessionId(); + + /** + * Invalidate the current session and clear session storage. + * @return completion notification (success or error) + */ + Mono invalidate(); + /** * Return {@code true} if the session expired after {@link #getMaxIdleTime() * maxIdleTime} elapsed. diff --git a/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java b/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java index 6ec24eaacb3..9b226edadc0 100644 --- a/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java +++ b/spring-web/src/main/java/org/springframework/web/server/session/InMemoryWebSessionStore.java @@ -207,6 +207,7 @@ public class InMemoryWebSessionStore implements WebSessionStore { private final AtomicReference state = new AtomicReference<>(State.NEW); + private final Lock lock = new ReentrantLock(); public InMemoryWebSession(Instant creationTime, Duration maxIdleTime) { this.creationTime = creationTime; @@ -256,29 +257,6 @@ public class InMemoryWebSessionStore implements WebSessionStore { return this.state.get().equals(State.STARTED) || !getAttributes().isEmpty(); } - @Override - public Mono changeSessionId() { - return Mono.defer(() -> { - String currentId = this.id.get(); - InMemoryWebSessionStore.this.sessions.remove(currentId); - String newId = String.valueOf(idGenerator.generateId()); - this.id.set(newId); - InMemoryWebSessionStore.this.sessions.put(this.id.get(), this); - return Mono.empty(); - }) - .subscribeOn(Schedulers.boundedElastic()) - .publishOn(Schedulers.parallel()) - .then(); - } - - @Override - public Mono invalidate() { - this.state.set(State.EXPIRED); - getAttributes().clear(); - InMemoryWebSessionStore.this.sessions.remove(this.id.get()); - return Mono.empty(); - } - @Override @SuppressWarnings("NullAway") // Dataflow analysis limitation public Mono save() { @@ -292,11 +270,19 @@ public class InMemoryWebSessionStore implements WebSessionStore { if (isStarted()) { // Save - InMemoryWebSessionStore.this.sessions.put(this.id.get(), this); + if (InMemoryWebSessionStore.this.sessions.get(getId()) == null) { + this.lock.lock(); + try { + InMemoryWebSessionStore.this.sessions.putIfAbsent(getId(), this); + } + finally { + this.lock.unlock(); + } + } // Unless it was invalidated if (this.state.get().equals(State.EXPIRED)) { - InMemoryWebSessionStore.this.sessions.remove(this.id.get()); + InMemoryWebSessionStore.this.sessions.remove(getId()); return Mono.error(new IllegalStateException("Session was invalidated")); } } @@ -307,12 +293,41 @@ public class InMemoryWebSessionStore implements WebSessionStore { private void checkMaxSessionsLimit() { if (sessions.size() >= maxSessions) { expiredSessionChecker.removeExpiredSessions(clock.instant()); - if (sessions.size() >= maxSessions && !sessions.containsKey(this.id.get())) { + if (sessions.size() >= maxSessions && !sessions.containsKey(getId())) { throw new IllegalStateException("Max sessions limit reached: " + sessions.size()); } } } + @Override + public Mono changeSessionId() { + return Mono.defer(() -> { + this.lock.lock(); + try { + String oldId = getId(); + String newId = String.valueOf(idGenerator.generateId()); + InMemoryWebSessionStore.this.sessions.remove(oldId); + InMemoryWebSessionStore.this.sessions.put(newId, this); + this.id.set(newId); + } + finally { + this.lock.unlock(); + } + return Mono.empty(); + }) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()) + .then(); + } + + @Override + public Mono invalidate() { + this.state.set(State.EXPIRED); + getAttributes().clear(); + InMemoryWebSessionStore.this.sessions.remove(getId()); + return Mono.empty(); + } + @Override public boolean isExpired() { return isExpired(clock.instant());