This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/CXF-9223 in repository https://gitbox.apache.org/repos/asf/cxf.git
commit cf8bb16a52bc5f8dc00df0b2b9dedd7e90e336f4 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Thu Jul 23 10:47:10 2026 +0100 CXF-9223 - Improve prefix matching for OAuth audience URIs --- .../oauth2/filters/OAuthRequestFilter.java | 26 ++++- .../oauth2/filters/OAuthRequestFilterTest.java | 119 +++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java index 0f5d8a04e62..72505c72130 100644 --- a/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java +++ b/rt/rs/security/oauth-parent/oauth2/src/main/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilter.java @@ -286,7 +286,9 @@ public class OAuthRequestFilter extends AbstractAccessTokenValidator } String requestPath = (String)PhaseInterceptorChain.getCurrentMessage().get(Message.REQUEST_URL); for (String s : audiences) { - boolean matched = completeAudienceMatch ? requestPath.equals(s) : requestPath.startsWith(s); + // In non-exact mode, only allow prefix matches at path/query/fragment boundaries. + boolean matched = completeAudienceMatch ? requestPath.equals(s) + : matchesAudiencePrefix(requestPath, s); if (matched) { return s; } @@ -295,6 +297,28 @@ public class OAuthRequestFilter extends AbstractAccessTokenValidator return null; } + /** + * Checks whether a configured audience matches a request URL using safe prefix semantics. + * <p> + * This keeps subtree-style matching (for example, "/api/read" matching "/api/read/item") + * but prevents same-prefix sibling matches (for example, "/api/readadmin"). + * A match is accepted only when the configured audience is an exact match, ends with '/', + * or is followed by a URL boundary character ('/', '?', '#'). + */ + protected boolean matchesAudiencePrefix(String requestPath, String configuredAudience) { + if (requestPath == null || configuredAudience == null) { + return false; + } + if (!requestPath.startsWith(configuredAudience)) { + return false; + } + if (requestPath.length() == configuredAudience.length() || configuredAudience.endsWith("/")) { + return true; + } + char boundary = requestPath.charAt(configuredAudience.length()); + return boundary == '/' || boundary == '?' || boundary == '#'; + } + public void setCheckFormData(boolean checkFormData) { this.checkFormData = checkFormData; } diff --git a/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java new file mode 100644 index 00000000000..61da08c5a61 --- /dev/null +++ b/rt/rs/security/oauth-parent/oauth2/src/test/java/org/apache/cxf/rs/security/oauth2/filters/OAuthRequestFilterTest.java @@ -0,0 +1,119 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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.apache.cxf.rs.security.oauth2.filters; + +import java.lang.reflect.Field; +import java.util.Collections; + +import jakarta.ws.rs.NotAuthorizedException; +import org.apache.cxf.message.Message; +import org.apache.cxf.message.MessageImpl; +import org.apache.cxf.phase.PhaseInterceptorChain; + +import org.junit.After; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +public class OAuthRequestFilterTest { + + @After + public void clearCurrentMessage() throws Exception { + setThreadLocalMessage(null); + } + + @Test + public void testValidateAudiencesMatchesSubPathInNonExactMode() throws Exception { + OAuthRequestFilter filter = new OAuthRequestFilter(); + filter.setAudienceIsEndpointAddress(true); + filter.setCompleteAudienceMatch(false); + + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "/api/read/item"); + setThreadLocalMessage(message); + + String result = filter.validateAudiences(Collections.singletonList("/api/read")); + assertEquals("/api/read", result); + } + + @Test + public void testValidateAudiencesRejectsSiblingPrefixInNonExactMode() throws Exception { + OAuthRequestFilter filter = new OAuthRequestFilter(); + filter.setAudienceIsEndpointAddress(true); + filter.setCompleteAudienceMatch(false); + + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "/api/readadmin"); + setThreadLocalMessage(message); + + assertThrows(NotAuthorizedException.class, + () -> filter.validateAudiences(Collections.singletonList("/api/read"))); + } + + @Test + public void testValidateAudiencesRequiresExactMatchWhenConfigured() throws Exception { + OAuthRequestFilter filter = new OAuthRequestFilter(); + filter.setAudienceIsEndpointAddress(true); + filter.setCompleteAudienceMatch(true); + + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "/api/read/item"); + setThreadLocalMessage(message); + + assertThrows(NotAuthorizedException.class, + () -> filter.validateAudiences(Collections.singletonList("/api/read"))); + } + + @Test + public void testValidateAudiencesSkipsEndpointCheckWhenDisabled() { + OAuthRequestFilter filter = new OAuthRequestFilter(); + filter.setAudienceIsEndpointAddress(false); + + String result = filter.validateAudiences(Collections.singletonList("/api/read")); + assertNull(result); + } + + @Test + public void testValidateAudiencesMatchesQueryBoundaryInNonExactMode() throws Exception { + OAuthRequestFilter filter = new OAuthRequestFilter(); + filter.setAudienceIsEndpointAddress(true); + filter.setCompleteAudienceMatch(false); + + Message message = new MessageImpl(); + message.put(Message.REQUEST_URL, "/api/read?include=details"); + setThreadLocalMessage(message); + + String result = filter.validateAudiences(Collections.singletonList("/api/read")); + assertEquals("/api/read", result); + } + + private static void setThreadLocalMessage(Message message) throws Exception { + Field f = PhaseInterceptorChain.class.getDeclaredField("CURRENT_MESSAGE"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + ThreadLocal<Message> tl = (ThreadLocal<Message>) f.get(null); + if (message == null) { + tl.remove(); + } else { + tl.set(message); + } + } +}
