diff --git a/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java b/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java
new file mode 100644
index 0000000000..e4ef11a1c9
--- /dev/null
+++ b/spring-web/src/main/java/org/springframework/web/filter/UrlHandlerFilter.java
@@ -0,0 +1,441 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.web.filter;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.RequestPath;
+import org.springframework.lang.Nullable;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.ServletRequestPathUtils;
+import org.springframework.web.util.pattern.PathPattern;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+/**
+ * {@code Filter} that can be configured to trim a trailing slashes, and either
+ * send a redirect or wrap the request and continue processing.
+ *
+ *
Use the static {@link #trimTrailingSlash(String...)} method to begin to
+ * configure and build an instance. For example:
+ *
+ *
+ * UrlHandlerFilter filter = UrlHandlerFilter
+ * .trimTrailingSlash("/path1/**").andRedirect(HttpStatus.PERMANENT_REDIRECT)
+ * .trimTrailingSlash("/path2/**").andHandleRequest()
+ * .build();
+ *
+ *
+ * Note that this {@code Filter} should be ordered after
+ * {@link ForwardedHeaderFilter} and before the Spring Security filter chain.
+ *
+ * @author Rossen Stoyanchev
+ * @since 6.2
+ */
+public final class UrlHandlerFilter extends OncePerRequestFilter {
+
+ private static final Log logger = LogFactory.getLog(UrlHandlerFilter.class);
+
+
+ private final Map handlers;
+
+
+ private UrlHandlerFilter(Map handlers) {
+ this.handlers = new LinkedHashMap<>(handlers);
+ }
+
+
+ @Override
+ protected boolean shouldNotFilterAsyncDispatch() {
+ return false;
+ }
+
+ @Override
+ protected boolean shouldNotFilterErrorDispatch() {
+ return false;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+
+ RequestPath previousPath = (RequestPath) request.getAttribute(ServletRequestPathUtils.PATH_ATTRIBUTE);
+ RequestPath path = previousPath;
+ try {
+ if (path == null) {
+ path = ServletRequestPathUtils.parseAndCache(request);
+ }
+ for (Map.Entry entry : this.handlers.entrySet()) {
+ Handler handler = entry.getValue();
+ if (entry.getKey().matches(path) && handler.shouldHandle(request)) {
+ handler.handle(request, response, chain);
+ return;
+ }
+ }
+ }
+ finally {
+ if (previousPath != null) {
+ ServletRequestPathUtils.setParsedRequestPath(previousPath, request);
+ }
+ }
+
+ chain.doFilter(request, response);
+ }
+
+
+ /**
+ * Begin to configure and build a {@link UrlHandlerFilter} by adding a
+ * trailing slash handler for the specified paths. For more details, see
+ * {@link Builder#trimTrailingSlash(String...)}.
+ * @param pathPatterns the URL patterns to which trimming applies.
+ * The pattern itself does not need to end with a trailing slash.
+ * @return a spec to continue with configuring the handler
+ */
+ public static TrailingSlashHandlerSpec trimTrailingSlash(String... pathPatterns) {
+ return new DefaultBuilder().trimTrailingSlash(pathPatterns);
+ }
+
+
+ /**
+ * Builder to configure and build a {@link UrlHandlerFilter}.
+ */
+ public interface Builder {
+
+ /**
+ * An entry point to configure a trim trailing slash handler.
+ * @param pathPatterns the URL patterns to which trimming applies.
+ * The pattern itself does not need to end with a trailing slash.
+ * @return a spec to continue with configuring the handler
+ */
+ TrailingSlashHandlerSpec trimTrailingSlash(String... pathPatterns);
+
+ /**
+ * Build the {@link UrlHandlerFilter} instance.
+ */
+ UrlHandlerFilter build();
+ }
+
+
+ /**
+ * A spec to configure a trailing slash handler.
+ */
+ public interface TrailingSlashHandlerSpec {
+
+ /**
+ * A callback to intercept requests with a trailing slash.
+ * @param consumer callback to be invoked for requests with a trailing slash
+ * @return the same spec instance
+ */
+ TrailingSlashHandlerSpec intercept(Consumer consumer);
+
+ /**
+ * Handle by sending a redirect with the given HTTP status and a location
+ * with the trailing slash trimmed.
+ * @param status the status to use
+ * @return to go back to the main {@link Builder} and either add more
+ * handlers or build the {@code Filter} instance.
+ */
+ Builder andRedirect(HttpStatus status);
+
+ /**
+ * Handle by wrapping the request with the trimmed trailing slash and
+ * delegating to the rest of the filter chain.
+ * @return to go back to the main {@link Builder} and either add more
+ * handlers or build the {@code Filter} instance.
+ */
+ Builder andHandleRequest();
+ }
+
+
+ /**
+ * Default {@link Builder} implementation.
+ */
+ private static final class DefaultBuilder implements Builder {
+
+ private final PathPatternParser patternParser = new PathPatternParser();
+
+ private final Map handlers = new LinkedHashMap<>();
+
+ @Override
+ public TrailingSlashHandlerSpec trimTrailingSlash(String... pathPatterns) {
+ return new DefaultTrailingSlashHandlerSpec(this, parseTrailingSlashPatterns(pathPatterns));
+ }
+
+ public void addHandler(List pathPatterns, Handler handler) {
+ for (PathPattern pattern : pathPatterns) {
+ this.handlers.put(pattern, handler);
+ }
+ }
+
+ private List parseTrailingSlashPatterns(String... patternValues) {
+ List patterns = new ArrayList<>(patternValues.length);
+ for (String s : patternValues) {
+ if (!s.endsWith("**") && s.charAt(s.length() - 1) != '/') {
+ s += "/";
+ }
+ patterns.add(this.patternParser.parse(s));
+ }
+ return patterns;
+ }
+
+ @Override
+ public UrlHandlerFilter build() {
+ return new UrlHandlerFilter(this.handlers);
+ }
+
+ }
+
+
+ /**
+ * Default {@link TrailingSlashHandlerSpec} implementation.
+ */
+ private static final class DefaultTrailingSlashHandlerSpec implements TrailingSlashHandlerSpec {
+
+ private static final Predicate trailingSlashPredicate =
+ request -> request.getRequestURI().endsWith("/");
+
+ private static final Function trimTralingSlashFunction = path -> {
+ int index = (StringUtils.hasLength(path) ? path.lastIndexOf('/') : -1);
+ return (index != -1 ? path.substring(0, index) : path);
+ };
+
+ private final DefaultBuilder parent;
+
+ private final List pathPatterns;
+
+ @Nullable
+ private Consumer interceptors;
+
+ private DefaultTrailingSlashHandlerSpec(DefaultBuilder parent, List pathPatterns) {
+ this.parent = parent;
+ this.pathPatterns = pathPatterns;
+ }
+
+ @Override
+ public TrailingSlashHandlerSpec intercept(Consumer interceptor) {
+ this.interceptors = (this.interceptors != null ? this.interceptors.andThen(interceptor) : interceptor);
+ return this;
+ }
+
+ @Override
+ public Builder andRedirect(HttpStatus status) {
+ return addHandler(new RedirectPathHandler(
+ trailingSlashPredicate, trimTralingSlashFunction, status, initInterceptor()));
+ }
+
+ @Override
+ public Builder andHandleRequest() {
+ return addHandler(new RequestWrappingPathHandler(
+ trailingSlashPredicate, trimTralingSlashFunction, initInterceptor()));
+ }
+
+ private Consumer initInterceptor() {
+ if (this.interceptors != null) {
+ return this.interceptors;
+ }
+ return request -> {
+ if (logger.isTraceEnabled()) {
+ logger.trace("Trimmed trailing slash: " +
+ request.getMethod() + " " + request.getRequestURI());
+ }
+ };
+ }
+
+ private DefaultBuilder addHandler(Handler handler) {
+ this.parent.addHandler(this.pathPatterns, handler);
+ return this.parent;
+ }
+ }
+
+
+ /**
+ * Internal handler for {@link UrlHandlerFilter} to delegate to.
+ */
+ private interface Handler {
+
+ /**
+ * Whether the handler handles the given request.
+ */
+ boolean shouldHandle(HttpServletRequest request);
+
+ /**
+ * Handle the request, possibly delegating to the rest of the filter chain.
+ */
+ void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException;
+ }
+
+
+ /**
+ * Base class for handlers that modify the URL path.
+ */
+ private abstract static class AbstractPathHandler implements Handler {
+
+ private final Predicate pathPredicate;
+
+ private final Function pathFunction;
+
+ private final Consumer interceptor;
+
+ AbstractPathHandler(
+ Predicate pathPredicate, Function pathFunction,
+ Consumer interceptor) {
+
+ this.pathPredicate = pathPredicate;
+ this.pathFunction = pathFunction;
+ this.interceptor = interceptor;
+ }
+
+ protected Function getPathFunction() {
+ return this.pathFunction;
+ }
+
+ @Override
+ public boolean shouldHandle(HttpServletRequest request) {
+ return this.pathPredicate.test(request);
+ }
+
+ @Override
+ public void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+
+ this.interceptor.accept(request);
+ handleInternal(request, response, chain);
+ }
+
+ protected abstract void handleInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException;
+ }
+
+
+ /**
+ * Path handler that sends a redirect.
+ */
+ private static final class RedirectPathHandler extends AbstractPathHandler {
+
+ private final HttpStatus httpStatus;
+
+ RedirectPathHandler(
+ Predicate pathPredicate, Function pathFunction,
+ HttpStatus httpStatus, Consumer interceptor) {
+
+ super(pathPredicate, pathFunction, interceptor);
+ this.httpStatus = httpStatus;
+ }
+
+ @Override
+ public void handleInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws IOException {
+
+ String location = getPathFunction().apply(request.getRequestURI());
+
+ response.resetBuffer();
+ response.setStatus(this.httpStatus.value());
+ response.setHeader(HttpHeaders.LOCATION, location);
+ response.flushBuffer();
+ }
+ }
+
+
+ /**
+ * Path handler that wraps the request and continues processing.
+ */
+ private static final class RequestWrappingPathHandler extends AbstractPathHandler {
+
+ RequestWrappingPathHandler(
+ Predicate pathPredicate, Function pathFunction,
+ Consumer interceptor) {
+
+ super(pathPredicate, pathFunction, interceptor);
+ }
+
+ @Override
+ public void handleInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws ServletException, IOException {
+
+ request = new PathHttpServletRequestWrapper(request, getPathFunction());
+ chain.doFilter(request, response);
+ }
+ }
+
+
+ /**
+ * Wraps the request to return modified path information.
+ */
+ private static class PathHttpServletRequestWrapper extends HttpServletRequestWrapper {
+
+ private final String requestURI;
+
+ private final StringBuffer requestURL;
+
+ private final String servletPath;
+
+ private final String pathInfo;
+
+ PathHttpServletRequestWrapper(HttpServletRequest request, Function pathFunction) {
+ super(request);
+ this.requestURI = pathFunction.apply(request.getRequestURI());
+ this.requestURL = new StringBuffer(pathFunction.apply(request.getRequestURL().toString()));
+ if (StringUtils.hasText(request.getPathInfo())) {
+ this.servletPath = request.getServletPath();
+ this.pathInfo = pathFunction.apply(request.getPathInfo());
+ }
+ else {
+ this.servletPath = pathFunction.apply(request.getServletPath());
+ this.pathInfo = request.getPathInfo();
+ }
+ }
+
+ @Override
+ public String getRequestURI() {
+ return this.requestURI;
+ }
+
+ @Override
+ public StringBuffer getRequestURL() {
+ return this.requestURL;
+ }
+
+ @Override
+ public String getServletPath() {
+ return this.servletPath;
+ }
+
+ @Override
+ public String getPathInfo() {
+ return this.pathInfo;
+ }
+ }
+
+}
diff --git a/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java b/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java
new file mode 100644
index 0000000000..7ecebd930d
--- /dev/null
+++ b/spring-web/src/test/java/org/springframework/web/filter/UrlHandlerFilterTests.java
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2002-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.web.filter;
+
+import java.io.IOException;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.lang.Nullable;
+import org.springframework.util.StringUtils;
+import org.springframework.web.testfixture.servlet.MockFilterChain;
+import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
+import org.springframework.web.testfixture.servlet.MockHttpServletResponse;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link UrlHandlerFilter}.
+ *
+ * @author Rossen Stoyanchev
+ */
+public class UrlHandlerFilterTests {
+
+ @Test
+ void trimTrailingSlashAndHandle() throws Exception {
+ testTrimTrailingSlashAndHandle("/path/**", "/path/123", null);
+ testTrimTrailingSlashAndHandle("/path/*", "/path", "/123");
+ testTrimTrailingSlashAndHandle("/path/*", "", "/path/123");
+ }
+
+ void testTrimTrailingSlashAndHandle(
+ String pattern, String servletPath, @Nullable String pathInfo) throws Exception {
+
+ UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash(pattern).andHandleRequest().build();
+
+ boolean hasPathInfo = StringUtils.hasLength(pathInfo);
+ String requestURI = servletPath + (hasPathInfo ? pathInfo : "");
+
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", requestURI + "/");
+ request.setServletPath(hasPathInfo ? servletPath : servletPath + "/");
+ request.setPathInfo(hasPathInfo ? pathInfo + "/" : pathInfo);
+
+ MockFilterChain chain = new MockFilterChain();
+ filter.doFilterInternal(request, new MockHttpServletResponse(), chain);
+
+ HttpServletRequest actual = (HttpServletRequest) chain.getRequest();
+ assertThat(actual).isNotNull().isNotSameAs(request);
+ assertThat(actual.getRequestURI()).isEqualTo(requestURI);
+ assertThat(actual.getRequestURL().toString()).isEqualTo("http://localhost" + requestURI);
+ assertThat(actual.getServletPath()).isEqualTo(servletPath);
+ assertThat(actual.getPathInfo()).isEqualTo(pathInfo);
+ }
+
+ @Test
+ void noTrailingSlashNoHandling() throws Exception {
+ testNoTrailingSlashNoHandling("/path/**", "/path/123");
+ testNoTrailingSlashNoHandling("/path/*", "/path/123");
+ }
+
+ private static void testNoTrailingSlashNoHandling(
+ String pattern, String requestURI) throws ServletException, IOException {
+
+ UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash(pattern).andHandleRequest().build();
+
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", requestURI);
+ MockFilterChain chain = new MockFilterChain();
+ filter.doFilterInternal(request, new MockHttpServletResponse(), chain);
+
+ HttpServletRequest actual = (HttpServletRequest) chain.getRequest();
+ assertThat(actual).as("Request should not be wrapped").isSameAs(request);
+ }
+
+ @Test
+ void trimTrailingSlashAndRedirect() throws Exception {
+ HttpStatus status = HttpStatus.PERMANENT_REDIRECT;
+ UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash("/path/*").andRedirect(status).build();
+
+ String path = "/path/123";
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ MockFilterChain chain = new MockFilterChain();
+ filter.doFilterInternal(new MockHttpServletRequest("GET", path + "/"), response, chain);
+
+ assertThat(chain.getRequest()).isNull();
+ assertThat(response.getStatus()).isEqualTo(status.value());
+ assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(path);
+ assertThat(response.isCommitted()).isTrue();
+ }
+
+ @Test
+ void noTrailingSlashNoRedirect() throws Exception {
+ HttpStatus status = HttpStatus.PERMANENT_REDIRECT;
+ UrlHandlerFilter filter = UrlHandlerFilter.trimTrailingSlash("/path/*").andRedirect(status).build();
+
+ MockHttpServletRequest request = new MockHttpServletRequest("GET", "/path/123");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ MockFilterChain chain = new MockFilterChain();
+ filter.doFilterInternal(request, response, chain);
+
+ assertThat(chain.getRequest()).isSameAs(request);
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getHeader(HttpHeaders.LOCATION)).isNull();
+ assertThat(response.isCommitted()).isFalse();
+ }
+
+}