Browse Source

Add PathPatternMessageMatcher

Closes gh-16500

Signed-off-by: Pat McCusker <patmccusker14@gmail.com>
Pat McCusker 5 months ago
parent
commit
33272ef0f4

+ 3 - 1
config/src/main/java/org/springframework/security/config/annotation/web/socket/MessageMatcherAuthorizationManagerConfiguration.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -21,6 +21,7 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Scope;
 import org.springframework.messaging.simp.annotation.support.SimpAnnotationMethodMessageHandler;
 import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager;
+import org.springframework.security.messaging.util.matcher.MessageMatcherFactory;
 import org.springframework.util.AntPathMatcher;
 
 final class MessageMatcherAuthorizationManagerConfiguration {
@@ -29,6 +30,7 @@ final class MessageMatcherAuthorizationManagerConfiguration {
 	@Scope("prototype")
 	MessageMatcherDelegatingAuthorizationManager.Builder messageAuthorizationManagerBuilder(
 			ApplicationContext context) {
+		MessageMatcherFactory.setApplicationContext(context);
 		return MessageMatcherDelegatingAuthorizationManager.builder()
 			.simpDestPathMatcher(
 					() -> (context.getBeanNamesForType(SimpAnnotationMethodMessageHandler.class).length > 0)

+ 48 - 36
messaging/src/main/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManager.java

@@ -17,6 +17,7 @@
 package org.springframework.security.messaging.access.intercept;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.function.Supplier;
@@ -34,10 +35,13 @@ import org.springframework.security.authorization.AuthorizationManager;
 import org.springframework.security.authorization.SingleResultAuthorizationManager;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.messaging.util.matcher.MessageMatcher;
+import org.springframework.security.messaging.util.matcher.MessageMatcherFactory;
+import org.springframework.security.messaging.util.matcher.PathPatternMessageMatcher;
 import org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher;
 import org.springframework.security.messaging.util.matcher.SimpMessageTypeMatcher;
 import org.springframework.util.AntPathMatcher;
 import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.PathMatcher;
 import org.springframework.util.function.SingletonSupplier;
 
@@ -85,15 +89,17 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 	}
 
 	private MessageAuthorizationContext<?> authorizationContext(MessageMatcher<?> matcher, Message<?> message) {
-		if (!matcher.matches((Message) message)) {
+		MessageMatcher.MatchResult matchResult = matcher.matcher((Message) message);
+		if (!matchResult.isMatch()) {
 			return null;
 		}
-		if (matcher instanceof SimpDestinationMessageMatcher simp) {
-			return new MessageAuthorizationContext<>(message, simp.extractPathVariables(message));
+
+		if (!CollectionUtils.isEmpty(matchResult.getVariables())) {
+			return new MessageAuthorizationContext<>(message, matchResult.getVariables());
 		}
-		if (matcher instanceof Builder.LazySimpDestinationMessageMatcher) {
-			Builder.LazySimpDestinationMessageMatcher path = (Builder.LazySimpDestinationMessageMatcher) matcher;
-			return new MessageAuthorizationContext<>(message, path.extractPathVariables(message));
+
+		if (matcher instanceof Builder.LazySimpDestinationMessageMatcher pathMatcher) {
+			return new MessageAuthorizationContext<>(message, pathMatcher.extractPathVariables(message));
 		}
 		return new MessageAuthorizationContext<>(message);
 	}
@@ -113,6 +119,7 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 
 		private final List<Entry<AuthorizationManager<MessageAuthorizationContext<?>>>> mappings = new ArrayList<>();
 
+		@Deprecated
 		private Supplier<PathMatcher> pathMatcher = AntPathMatcher::new;
 
 		public Builder() {
@@ -133,11 +140,11 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 		 * @return the Expression to associate
 		 */
 		public Builder.Constraint nullDestMatcher() {
-			return matchers(SimpDestinationMessageMatcher.NULL_DESTINATION_MATCHER);
+			return matchers(PathPatternMessageMatcher.NULL_DESTINATION_MATCHER);
 		}
 
 		/**
-		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances.
+		 * Maps a {@link List} of {@link SimpMessageTypeMatcher} instances.
 		 * @param typesToMatch the {@link SimpMessageType} instance to match on
 		 * @return the {@link Builder.Constraint} associated to the matchers.
 		 */
@@ -151,56 +158,58 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 		}
 
 		/**
-		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances without
-		 * regard to the {@link SimpMessageType}. If no destination is found on the
-		 * Message, then the Matcher returns false.
-		 * @param patterns the patterns to create
-		 * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher}
-		 * from.
+		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} (or
+		 * {@link PathPatternMessageMatcher} if the application has configured a
+		 * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean})
+		 * instances without regard to the {@link SimpMessageType}. If no destination is
+		 * found on the Message, then the Matcher returns false.
+		 * @param patterns the patterns to create {@code MessageMatcher}s from.
 		 */
 		public Builder.Constraint simpDestMatchers(String... patterns) {
 			return simpDestMatchers(null, patterns);
 		}
 
 		/**
-		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances that
-		 * match on {@code SimpMessageType.MESSAGE}. If no destination is found on the
-		 * Message, then the Matcher returns false.
-		 * @param patterns the patterns to create
-		 * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher}
-		 * from.
+		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} (or
+		 * {@link PathPatternMessageMatcher} if the application has configured a
+		 * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean})
+		 * instances that match on {@code SimpMessageType.MESSAGE}. If no destination is
+		 * found on the Message, then the Matcher returns false.
+		 * @param patterns the patterns to create {@code MessageMatcher}s from.
 		 */
 		public Builder.Constraint simpMessageDestMatchers(String... patterns) {
 			return simpDestMatchers(SimpMessageType.MESSAGE, patterns);
 		}
 
 		/**
-		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances that
-		 * match on {@code SimpMessageType.SUBSCRIBE}. If no destination is found on the
-		 * Message, then the Matcher returns false.
-		 * @param patterns the patterns to create
-		 * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher}
-		 * from.
+		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} (or
+		 * {@link PathPatternMessageMatcher} if the application has configured a
+		 * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean})
+		 * instances that match on {@code SimpMessageType.SUBSCRIBE}. If no destination is
+		 * found on the Message, then the Matcher returns false.
+		 * @param patterns the patterns to create {@code MessageMatcher}s from.
 		 */
 		public Builder.Constraint simpSubscribeDestMatchers(String... patterns) {
 			return simpDestMatchers(SimpMessageType.SUBSCRIBE, patterns);
 		}
 
 		/**
-		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances. If no
-		 * destination is found on the Message, then the Matcher returns false.
+		 * Maps a {@link List} of {@link SimpDestinationMessageMatcher} instances, or
+		 * {@link PathPatternMessageMatcher} if the application has configured a
+		 * {@link org.springframework.security.messaging.util.matcher.PathPatternMessageMatcherBuilderFactoryBean}.
+		 * If no destination is found on the Message, then the Matcher returns false.
 		 * @param type the {@link SimpMessageType} to match on. If null, the
 		 * {@link SimpMessageType} is not considered for matching.
-		 * @param patterns the patterns to create
-		 * {@link org.springframework.security.messaging.util.matcher.SimpDestinationMessageMatcher}
-		 * from.
+		 * @param patterns the patterns to create {@code MessageMatcher}s from.
 		 * @return the {@link Builder.Constraint} that is associated to the
 		 * {@link MessageMatcher}
 		 */
 		private Builder.Constraint simpDestMatchers(SimpMessageType type, String... patterns) {
 			List<MessageMatcher<?>> matchers = new ArrayList<>(patterns.length);
 			for (String pattern : patterns) {
-				MessageMatcher<Object> matcher = new LazySimpDestinationMessageMatcher(pattern, type);
+				MessageMatcher<Object> matcher = MessageMatcherFactory.usesPathPatterns()
+						? MessageMatcherFactory.matcher(pattern, type)
+						: new LazySimpDestinationMessageMatcher(pattern, type);
 				matchers.add(matcher);
 			}
 			return new Builder.Constraint(matchers);
@@ -212,7 +221,9 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 		 * constructor of {@link AntPathMatcher}.
 		 * @param pathMatcher the {@link PathMatcher} to use. Cannot be null.
 		 * @return the {@link Builder} for further customization.
+		 * @deprecated
 		 */
+		@Deprecated
 		public Builder simpDestPathMatcher(PathMatcher pathMatcher) {
 			Assert.notNull(pathMatcher, "pathMatcher cannot be null");
 			this.pathMatcher = () -> pathMatcher;
@@ -225,7 +236,9 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 		 * computation or lookup of the {@link PathMatcher}.
 		 * @param pathMatcher the {@link PathMatcher} to use. Cannot be null.
 		 * @return the {@link Builder} for further customization.
+		 * @deprecated
 		 */
+		@Deprecated
 		public Builder simpDestPathMatcher(Supplier<PathMatcher> pathMatcher) {
 			Assert.notNull(pathMatcher, "pathMatcher cannot be null");
 			this.pathMatcher = pathMatcher;
@@ -241,9 +254,7 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 		 */
 		public Builder.Constraint matchers(MessageMatcher<?>... matchers) {
 			List<MessageMatcher<?>> builders = new ArrayList<>(matchers.length);
-			for (MessageMatcher<?> matcher : matchers) {
-				builders.add(matcher);
-			}
+			builders.addAll(Arrays.asList(matchers));
 			return new Builder.Constraint(builders);
 		}
 
@@ -382,6 +393,7 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 
 		}
 
+		@Deprecated
 		private final class LazySimpDestinationMessageMatcher implements MessageMatcher<Object> {
 
 			private final Supplier<SimpDestinationMessageMatcher> delegate;
@@ -421,7 +433,7 @@ public final class MessageMatcherDelegatingAuthorizationManager implements Autho
 
 		private final T entry;
 
-		Entry(MessageMatcher requestMatcher, T entry) {
+		Entry(MessageMatcher<?> requestMatcher, T entry) {
 			this.messageMatcher = requestMatcher;
 			this.entry = entry;
 		}

+ 46 - 0
messaging/src/main/java/org/springframework/security/messaging/util/matcher/MessageMatcherFactory.java

@@ -0,0 +1,46 @@
+/*
+ * Copyright 2002-2025 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.security.messaging.util.matcher;
+
+import org.springframework.context.ApplicationContext;
+import org.springframework.messaging.simp.SimpMessageType;
+
+@Deprecated(forRemoval = true)
+public final class MessageMatcherFactory {
+
+	private static PathPatternMessageMatcher.Builder builder;
+
+	public static void setApplicationContext(ApplicationContext context) {
+		builder = context.getBeanProvider(PathPatternMessageMatcher.Builder.class).getIfUnique();
+	}
+
+	public static boolean usesPathPatterns() {
+		return builder != null;
+	}
+
+	public static MessageMatcher<?> matcher(String destination) {
+		return builder.matcher(destination);
+	}
+
+	public static MessageMatcher<Object> matcher(String destination, SimpMessageType type) {
+		return (type != null) ? builder.matcher(destination, type) : builder.matcher(destination);
+	}
+
+	private MessageMatcherFactory() {
+	}
+
+}

+ 151 - 0
messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcher.java

@@ -0,0 +1,151 @@
+/*
+ * Copyright 2002-2025 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.security.messaging.util.matcher;
+
+import java.util.Collections;
+
+import org.springframework.http.server.PathContainer;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
+import org.springframework.messaging.simp.SimpMessageType;
+import org.springframework.util.Assert;
+import org.springframework.web.util.pattern.PathPattern;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+/**
+ * Match {@link Message}s based on the message destination pattern using a
+ * {@link PathPattern}. There is also support for optionally matching on a specified
+ * {@link SimpMessageType}.
+ *
+ * @author Pat McCusker
+ * @since 6.5
+ */
+public final class PathPatternMessageMatcher implements MessageMatcher<Object> {
+
+	public static final MessageMatcher<Object> NULL_DESTINATION_MATCHER = (message) -> getDestination(message) == null;
+
+	private final PathPattern pattern;
+
+	private final PathPatternParser parser;
+
+	/**
+	 * The {@link MessageMatcher} that determines if the type matches. If the type was
+	 * null, this matcher will match every Message.
+	 */
+	private MessageMatcher<Object> messageTypeMatcher = ANY_MESSAGE;
+
+	private PathPatternMessageMatcher(PathPattern pattern, PathPatternParser parser) {
+		this.parser = parser;
+		this.pattern = pattern;
+	}
+
+	/**
+	 * Initialize this builder with the {@link PathPatternParser#defaultInstance} that is
+	 * configured with the
+	 * {@link org.springframework.http.server.PathContainer.Options#HTTP_PATH} separator
+	 */
+	public static Builder withDefaults() {
+		return new Builder(PathPatternParser.defaultInstance);
+	}
+
+	/**
+	 * Initialize this builder with the provided {@link PathPatternParser}
+	 */
+	public static Builder withPathPatternParser(PathPatternParser parser) {
+		return new Builder(parser);
+	}
+
+	void setMessageTypeMatcher(MessageMatcher<Object> messageTypeMatcher) {
+		this.messageTypeMatcher = messageTypeMatcher;
+	}
+
+	/**
+	 * {@inheritDoc}
+	 */
+	@Override
+	public boolean matches(Message<?> message) {
+		if (!this.messageTypeMatcher.matches(message)) {
+			return false;
+		}
+
+		String destination = getDestination(message);
+		if (destination == null) {
+			return false;
+		}
+
+		PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.parser.getPathOptions());
+		return this.pattern.matches(destinationPathContainer);
+	}
+
+	/**
+	 * Extract the path variables from the {@link Message} destination if the path is a
+	 * match, otherwise the {@link MatchResult#getVariables()} returns a
+	 * {@link Collections#emptyMap()}
+	 * @param message the message whose path variables to extract.
+	 * @return a {@code MatchResult} of the path variables and values.
+	 */
+	@Override
+	public MatchResult matcher(Message<?> message) {
+		if (!this.messageTypeMatcher.matches(message)) {
+			return MatchResult.notMatch();
+		}
+
+		String destination = getDestination(message);
+		if (destination == null) {
+			return MatchResult.notMatch();
+		}
+
+		PathContainer destinationPathContainer = PathContainer.parsePath(destination, this.parser.getPathOptions());
+		PathPattern.PathMatchInfo pathMatchInfo = this.pattern.matchAndExtract(destinationPathContainer);
+
+		return (pathMatchInfo != null) ? MatchResult.match(pathMatchInfo.getUriVariables()) : MatchResult.notMatch();
+	}
+
+	private static String getDestination(Message<?> message) {
+		return SimpMessageHeaderAccessor.getDestination(message.getHeaders());
+	}
+
+	public static class Builder {
+
+		private final PathPatternParser parser;
+
+		private MessageMatcher<Object> messageTypeMatcher = ANY_MESSAGE;
+
+		Builder(PathPatternParser parser) {
+			this.parser = parser;
+		}
+
+		public PathPatternMessageMatcher matcher(String pattern) {
+			Assert.notNull(pattern, "Pattern must not be null");
+			PathPattern pathPattern = this.parser.parse(pattern);
+			PathPatternMessageMatcher matcher = new PathPatternMessageMatcher(pathPattern, this.parser);
+			if (this.messageTypeMatcher != ANY_MESSAGE) {
+				matcher.setMessageTypeMatcher(this.messageTypeMatcher);
+			}
+			return matcher;
+		}
+
+		public PathPatternMessageMatcher matcher(String pattern, SimpMessageType type) {
+			Assert.notNull(type, "Type must not be null");
+			this.messageTypeMatcher = new SimpMessageTypeMatcher(type);
+
+			return matcher(pattern);
+		}
+
+	}
+
+}

+ 54 - 0
messaging/src/main/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBean.java

@@ -0,0 +1,54 @@
+/*
+ * Copyright 2002-2025 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.security.messaging.util.matcher;
+
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+/**
+ * Use this factory bean to configure the {@link PathPatternMessageMatcher.Builder} bean
+ * used to create request matchers in
+ * {@link org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager}
+ * and other parts of the DSL.
+ *
+ * @author Pat McCusker
+ * @since 6.5
+ */
+public class PathPatternMessageMatcherBuilderFactoryBean implements FactoryBean<PathPatternMessageMatcher.Builder> {
+
+	private final PathPatternParser parser;
+
+	public PathPatternMessageMatcherBuilderFactoryBean() {
+		this(null);
+	}
+
+	public PathPatternMessageMatcherBuilderFactoryBean(PathPatternParser parser) {
+		this.parser = parser;
+	}
+
+	@Override
+	public PathPatternMessageMatcher.Builder getObject() throws Exception {
+		return (this.parser != null) ? PathPatternMessageMatcher.withPathPatternParser(this.parser)
+				: PathPatternMessageMatcher.withDefaults();
+	}
+
+	@Override
+	public Class<?> getObjectType() {
+		return PathPatternMessageMatcher.Builder.class;
+	}
+
+}

+ 3 - 1
messaging/src/main/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcher.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -35,7 +35,9 @@ import org.springframework.util.PathMatcher;
  *
  * @author Rob Winch
  * @since 4.0
+ * @deprecated use {@link PathPatternMessageMatcher}
  */
+@Deprecated
 public final class SimpDestinationMessageMatcher implements MessageMatcher<Object> {
 
 	public static final MessageMatcher<Object> NULL_DESTINATION_MATCHER = (message) -> {

+ 65 - 13
messaging/src/test/java/org/springframework/security/messaging/access/intercept/MessageMatcherDelegatingAuthorizationManagerTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2023 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -19,8 +19,15 @@ package org.springframework.security.messaging.access.intercept;
 import java.util.Map;
 import java.util.function.Supplier;
 
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
 
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.context.ApplicationContext;
 import org.springframework.messaging.Message;
 import org.springframework.messaging.MessageHeaders;
 import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
@@ -30,6 +37,8 @@ import org.springframework.security.authentication.TestingAuthenticationToken;
 import org.springframework.security.authorization.AuthorizationDecision;
 import org.springframework.security.authorization.AuthorizationManager;
 import org.springframework.security.core.Authentication;
+import org.springframework.security.messaging.util.matcher.MessageMatcherFactory;
+import org.springframework.security.messaging.util.matcher.PathPatternMessageMatcher;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.Mockito.mock;
@@ -37,8 +46,21 @@ import static org.mockito.Mockito.mock;
 /**
  * Tests for {@link MessageMatcherDelegatingAuthorizationManager}
  */
+@ExtendWith(MockitoExtension.class)
 public final class MessageMatcherDelegatingAuthorizationManagerTests {
 
+	@Mock
+	private ApplicationContext context;
+
+	@Mock
+	private ObjectProvider<PathPatternMessageMatcher.Builder> provider;
+
+	@BeforeEach
+	void setUp() {
+		Mockito.when(this.context.getBeanProvider(PathPatternMessageMatcher.Builder.class)).thenReturn(this.provider);
+		MessageMatcherFactory.setApplicationContext(this.context);
+	}
+
 	@Test
 	void checkWhenPermitAllThenPermits() {
 		AuthorizationManager<Message<?>> authorizationManager = builder().anyMessage().permitAll().build();
@@ -58,13 +80,13 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests {
 
 	@Test
 	void checkWhenSimpDestinationMatchesThenUses() {
-		AuthorizationManager<Message<?>> authorizationManager = builder().simpDestMatchers("destination")
+		AuthorizationManager<Message<?>> authorizationManager = builder().simpDestMatchers("/destination")
 			.permitAll()
 			.anyMessage()
 			.denyAll()
 			.build();
 		MessageHeaders headers = new MessageHeaders(
-				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination"));
+				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination"));
 		Message<?> message = new GenericMessage<>(new Object(), headers);
 		assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue();
 	}
@@ -79,7 +101,7 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests {
 		Message<?> message = new GenericMessage<>(new Object());
 		assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue();
 		MessageHeaders headers = new MessageHeaders(
-				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination"));
+				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination"));
 		message = new GenericMessage<>(new Object(), headers);
 		assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isFalse();
 	}
@@ -100,17 +122,53 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests {
 	// gh-12540
 	@Test
 	void checkWhenSimpDestinationMatchesThenVariablesExtracted() {
-		AuthorizationManager<Message<?>> authorizationManager = builder().simpDestMatchers("destination/{id}")
+		AuthorizationManager<Message<?>> authorizationManager = builder().simpDestMatchers("/destination/*/{id}")
 			.access(variable("id").isEqualTo("3"))
 			.anyMessage()
 			.denyAll()
 			.build();
 		MessageHeaders headers = new MessageHeaders(
-				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination/3"));
+				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/sub/3"));
 		Message<?> message = new GenericMessage<>(new Object(), headers);
 		assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isTrue();
 	}
 
+	@Test
+	void checkWhenMessageTypeAndPathPatternMatches() {
+		Mockito.when(this.provider.getIfUnique()).thenReturn(PathPatternMessageMatcher.withDefaults());
+		MessageMatcherFactory.setApplicationContext(this.context);
+		AuthorizationManager<Message<?>> authorizationManager = builder().simpMessageDestMatchers("/destination")
+			.permitAll()
+			.simpSubscribeDestMatchers("/destination")
+			.denyAll()
+			.anyMessage()
+			.denyAll()
+			.build();
+		MessageHeaders headers = new MessageHeaders(Map.of(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER,
+				SimpMessageType.MESSAGE, SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination"));
+		Message<?> message = new GenericMessage<>(new Object(), headers);
+		assertThat(authorizationManager.authorize(mock(Supplier.class), message).isGranted()).isTrue();
+		MessageHeaders headers2 = new MessageHeaders(Map.of(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER,
+				SimpMessageType.SUBSCRIBE, SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination"));
+		Message<?> message2 = new GenericMessage<>(new Object(), headers2);
+		assertThat(authorizationManager.check(mock(Supplier.class), message2).isGranted()).isFalse();
+	}
+
+	@Test
+	void checkPatternMismatch() {
+		Mockito.when(this.provider.getIfUnique()).thenReturn(PathPatternMessageMatcher.withDefaults());
+		MessageMatcherFactory.setApplicationContext(this.context);
+		AuthorizationManager<Message<?>> authorizationManager = builder().simpDestMatchers("/destination/*")
+			.permitAll()
+			.anyMessage()
+			.denyAll()
+			.build();
+		MessageHeaders headers = new MessageHeaders(
+				Map.of(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/sub/asdf"));
+		Message<?> message = new GenericMessage<>(new Object(), headers);
+		assertThat(authorizationManager.check(mock(Supplier.class), message).isGranted()).isFalse();
+	}
+
 	private MessageMatcherDelegatingAuthorizationManager.Builder builder() {
 		return MessageMatcherDelegatingAuthorizationManager.builder();
 	}
@@ -120,13 +178,7 @@ public final class MessageMatcherDelegatingAuthorizationManagerTests {
 
 	}
 
-	private static final class Builder {
-
-		private final String name;
-
-		private Builder(String name) {
-			this.name = name;
-		}
+	private record Builder(String name) {
 
 		AuthorizationManager<MessageAuthorizationContext<?>> isEqualTo(String value) {
 			return (authentication, object) -> {

+ 62 - 0
messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherBuilderFactoryBeanTests.java

@@ -0,0 +1,62 @@
+/*
+ * Copyright 2002-2025 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.security.messaging.util.matcher;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+class PathPatternMessageMatcherBuilderFactoryBeanTests {
+
+	GenericApplicationContext context;
+
+	@BeforeEach
+	void setUp() {
+		this.context = new GenericApplicationContext();
+	}
+
+	@Test
+	void getObjectWhenDefaultsThenBuilder() throws Exception {
+		factoryBean().getObject();
+	}
+
+	@Test
+	void getObjectWithCustomParserThenUses() throws Exception {
+		PathPatternParser parser = mock(PathPatternParser.class);
+		PathPatternMessageMatcher.Builder builder = factoryBean(parser).getObject();
+
+		builder.matcher("/path/**");
+		verify(parser).parse("/path/**");
+	}
+
+	PathPatternMessageMatcherBuilderFactoryBean factoryBean() {
+		PathPatternMessageMatcherBuilderFactoryBean factoryBean = new PathPatternMessageMatcherBuilderFactoryBean();
+		return factoryBean;
+	}
+
+	PathPatternMessageMatcherBuilderFactoryBean factoryBean(PathPatternParser parser) {
+		PathPatternMessageMatcherBuilderFactoryBean factoryBean = new PathPatternMessageMatcherBuilderFactoryBean(
+				parser);
+		return factoryBean;
+	}
+
+}

+ 155 - 0
messaging/src/test/java/org/springframework/security/messaging/util/matcher/PathPatternMessageMatcherTests.java

@@ -0,0 +1,155 @@
+/*
+ * Copyright 2002-2025 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.security.messaging.util.matcher;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.server.PathContainer;
+import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
+import org.springframework.messaging.simp.SimpMessageType;
+import org.springframework.messaging.support.MessageBuilder;
+import org.springframework.web.util.pattern.PathPatternParser;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+public class PathPatternMessageMatcherTests {
+
+	MessageBuilder<String> messageBuilder;
+
+	PathPatternMessageMatcher matcher;
+
+	@BeforeEach
+	void setUp() {
+		this.messageBuilder = MessageBuilder.withPayload("M");
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/**");
+	}
+
+	@Test
+	void constructorPatternNull() {
+		assertThatIllegalArgumentException().isThrownBy(() -> PathPatternMessageMatcher.withDefaults().matcher(null));
+	}
+
+	@Test
+	void matchesDoesNotMatchNullDestination() {
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse();
+	}
+
+	@Test
+	void matchesTrueWithSpecificDestinationPattern() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/destination/1");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1");
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue();
+	}
+
+	@Test
+	void matchesFalseWithDifferentDestination() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/nomatch");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1");
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse();
+	}
+
+	@Test
+	void matchesTrueWithDotSeparator() {
+		this.matcher = PathPatternMessageMatcher.withPathPatternParser(dotSeparatedPathParser())
+			.matcher("destination.1");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination.1");
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue();
+	}
+
+	@Test
+	void matchesFalseWithDotSeparatorAndAdditionalWildcardPathSegment() {
+		this.matcher = PathPatternMessageMatcher.withPathPatternParser(dotSeparatedPathParser())
+			.matcher("/destination/a.*");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/a.b");
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue();
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/a.b.c");
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse();
+	}
+
+	@Test
+	void matchesFalseWithDifferentMessageType() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.MESSAGE);
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.DISCONNECT);
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match");
+
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isFalse();
+	}
+
+	@Test
+	public void matchesTrueMessageType() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.MESSAGE);
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.MESSAGE);
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue();
+	}
+
+	@Test
+	public void matchesTrueSubscribeType() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/match", SimpMessageType.SUBSCRIBE);
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/match");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.SUBSCRIBE);
+		assertThat(this.matcher.matches(this.messageBuilder.build())).isTrue();
+	}
+
+	@Test
+	void extractPathVariablesFromDestination() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/topics/{topic}/**");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/topics/someTopic/sub1");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.MESSAGE);
+
+		MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build());
+		assertThat(matchResult.isMatch()).isTrue();
+		assertThat(matchResult.getVariables()).containsEntry("topic", "someTopic");
+	}
+
+	@Test
+	void extractPathVariablesFromMessageDestinationPath() {
+		this.matcher = PathPatternMessageMatcher.withPathPatternParser(dotSeparatedPathParser())
+			.matcher("destination.{destinationNum}");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "destination.1");
+		MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build());
+		assertThat(matchResult.getVariables()).containsEntry("destinationNum", "1");
+	}
+
+	@Test
+	void extractPathVariables_isEmptyWithNullDestination() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/topics/{topic}/**");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.MESSAGE_TYPE_HEADER, SimpMessageType.MESSAGE);
+
+		MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build());
+		assertThat(matchResult.isMatch()).isFalse();
+		assertThat(matchResult.getVariables()).isEmpty();
+	}
+
+	@Test
+	void getUriVariablesIsEmpty_onExtractPathVariables_whenNoMatch() {
+		this.matcher = PathPatternMessageMatcher.withDefaults().matcher("/nomatch");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1");
+		MessageMatcher.MatchResult matchResult = this.matcher.matcher(this.messageBuilder.build());
+		assertThat(matchResult.isMatch()).isFalse();
+		assertThat(matchResult.getVariables()).isEmpty();
+	}
+
+	private static PathPatternParser dotSeparatedPathParser() {
+		PathPatternParser parser = new PathPatternParser();
+		parser.setPathOptions(PathContainer.Options.MESSAGE_ROUTE);
+		return parser;
+	}
+
+}

+ 10 - 1
messaging/src/test/java/org/springframework/security/messaging/util/matcher/SimpDestinationMessageMatcherTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2019 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -27,6 +27,7 @@ import org.springframework.util.PathMatcher;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
 
 public class SimpDestinationMessageMatcherTests {
 
@@ -129,4 +130,12 @@ public class SimpDestinationMessageMatcherTests {
 		assertThat(this.matcher.getMessageTypeMatcher()).isEqualTo(expectedTypeMatcher);
 	}
 
+	@Test
+	void illegalStateExceptionThrown_onExtractPathVariables_whenNoMatch() {
+		this.matcher = new SimpDestinationMessageMatcher("/nomatch");
+		this.messageBuilder.setHeader(SimpMessageHeaderAccessor.DESTINATION_HEADER, "/destination/1");
+		assertThatIllegalStateException()
+			.isThrownBy(() -> this.matcher.extractPathVariables(this.messageBuilder.build()));
+	}
+
 }