This is an automated email from the ASF dual-hosted git repository. reta pushed a commit to branch 3.6.x-fixes in repository https://gitbox.apache.org/repos/asf/cxf.git
commit 362aeab192a3d724e6a1280b8c5bea62076ba619 Author: Javid Khan <[email protected]> AuthorDate: Thu Jul 2 05:44:52 2026 +0530 validate redirect origin in oidc rp sign-in completion (#3241) * validate redirect origin in oidc rp sign-in completion Signed-off-by: dxbjavid <[email protected]> * harden redirect state in oidc rp filter rather than completion service * derive same-origin base from request uri info instead of message context * add filter test for state with scheme but no authority Signed-off-by: Javid Khan <[email protected]> --------- Signed-off-by: dxbjavid <[email protected]> Signed-off-by: Javid Khan <[email protected]> (cherry picked from commit d36cff46abfda921b72b15bd01116017cea4c5d7) --- rt/rs/security/sso/oidc/pom.xml | 6 ++ .../oidc/rp/OidcRpAuthenticationFilter.java | 25 +++++ .../oidc/rp/OidcRpAuthenticationFilterTest.java | 106 +++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/rt/rs/security/sso/oidc/pom.xml b/rt/rs/security/sso/oidc/pom.xml index b882d52f416..ad0cff2c102 100644 --- a/rt/rs/security/sso/oidc/pom.xml +++ b/rt/rs/security/sso/oidc/pom.xml @@ -62,6 +62,12 @@ <artifactId>junit</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>${cxf.mockito.version}</version> + <scope>test</scope> + </dependency> <dependency> <groupId>org.hsqldb</groupId> <artifactId>hsqldb</artifactId> diff --git a/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilter.java b/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilter.java index 8f4add8a600..d99410609f3 100644 --- a/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilter.java +++ b/rt/rs/security/sso/oidc/src/main/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilter.java @@ -116,8 +116,33 @@ public class OidcRpAuthenticationFilter implements ContainerRequestFilter { rc.setEntityStream(new ByteArrayInputStream(StringUtils.toBytesUTF8(body))); } + // The "state" carried here is read back by the sign-in completion service and returned + // as a redirect Location, so a caller-supplied value collides with the redirect query + // the filter itself writes. Anything that is not within this application's own origin is + // dropped, otherwise completion would become an open redirect. + String location = requestState.getFirst("state"); + if (location != null && !isSameOrigin(rc, location)) { + requestState.remove("state"); + } return requestState; } + private boolean isSameOrigin(ContainerRequestContext rc, String location) { + final URI uri; + try { + uri = URI.create(location); + } catch (IllegalArgumentException ex) { + return false; + } + if (uri.getScheme() == null && uri.getAuthority() == null) { + // a path-only reference is resolved by the browser against the current request + return true; + } + URI base = rc.getUriInfo().getAbsolutePath(); + return uri.getScheme() != null + && uri.getScheme().equalsIgnoreCase(base.getScheme()) + && uri.getAuthority() != null + && uri.getAuthority().equalsIgnoreCase(base.getAuthority()); + } public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } diff --git a/rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilterTest.java b/rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilterTest.java new file mode 100644 index 00000000000..8b2eeba7729 --- /dev/null +++ b/rt/rs/security/sso/oidc/src/test/java/org/apache/cxf/rs/security/oidc/rp/OidcRpAuthenticationFilterTest.java @@ -0,0 +1,106 @@ +/** + * 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.oidc.rp; + +import java.lang.reflect.Method; +import java.net.URI; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.UriInfo; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class OidcRpAuthenticationFilterTest { + + private static final URI ABSOLUTE_PATH = URI.create("https://app.example.com:8080/services/rp/complete"); + + @Test + public void testDropsCrossOriginState() { + MultivaluedMap<String, String> state = requestState("https://evil.example.com/phish"); + assertFalse(state.containsKey("state")); + } + + @Test + public void testDropsProtocolRelativeState() { + MultivaluedMap<String, String> state = requestState("//evil.example.com/phish"); + assertFalse(state.containsKey("state")); + } + + @Test + public void testDropsUserinfoHostConfusionState() { + MultivaluedMap<String, String> state = requestState("https://app.example.com:[email protected]/phish"); + assertFalse(state.containsKey("state")); + } + + @Test + public void testDropsNoAuthority() { + MultivaluedMap<String, String> state = requestState("http:/"); + assertFalse(state.containsKey("state")); + } + + @Test + public void testKeepsSameOriginState() { + MultivaluedMap<String, String> state = requestState("https://app.example.com:8080/services/protected"); + assertTrue(state.containsKey("state")); + assertEquals("https://app.example.com:8080/services/protected", state.getFirst("state")); + } + + @Test + public void testKeepsRelativeState() { + MultivaluedMap<String, String> state = requestState("/services/protected"); + assertTrue(state.containsKey("state")); + assertEquals("/services/protected", state.getFirst("state")); + } + + private MultivaluedMap<String, String> requestState(String stateLocation) { + MultivaluedMap<String, String> query = new MultivaluedHashMap<>(); + query.putSingle("state", stateLocation); + + UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getQueryParameters(true)).thenReturn(query); + when(uriInfo.getAbsolutePath()).thenReturn(ABSOLUTE_PATH); + + ContainerRequestContext rc = mock(ContainerRequestContext.class); + when(rc.getUriInfo()).thenReturn(uriInfo); + when(rc.getMediaType()).thenReturn(null); + + return invokeToRequestState(new OidcRpAuthenticationFilter(), rc); + } + + @SuppressWarnings("unchecked") + private static MultivaluedMap<String, String> invokeToRequestState(OidcRpAuthenticationFilter filter, + ContainerRequestContext rc) { + try { + Method method = OidcRpAuthenticationFilter.class.getDeclaredMethod("toRequestState", + ContainerRequestContext.class); + method.setAccessible(true); + return (MultivaluedMap<String, String>)method.invoke(filter, rc); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException(ex); + } + } +}
