V!Z
delta 19
bcmew>vq^Hp12#s6%@OPqST@`6Ix+$POpOL$
diff --git a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
index d11d65a4bb..6a53630cff 100644
--- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
+++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
@@ -15,6 +15,8 @@
*/
package com.sun.identity.federation.common;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
import java.util.Locale;
/**
@@ -23,6 +25,12 @@
* it can reach {@code /WEB-INF} and {@code /META-INF}, and it does not run the
* filters declared in {@code web.xml}, so a path an end user controls must never
* be allowed to climb out of the location the code intends to dispatch to.
+ *
+ * The container percent-decodes a dispatcher path once more before it normalises
+ * it (Tomcat: {@code dispatchersUseEncodedPaths}, on by default), so every check
+ * runs on the decoded form as well as on the raw one, and a value that would
+ * still carry an escape after that single decode - a second encoding layer - is
+ * refused outright: no in-app path of the product needs one.
*/
public final class ForwardPathValidator {
@@ -31,8 +39,9 @@ private ForwardPathValidator() {
/**
* Whether {@code path} may be forwarded to as-is: absolute, without {@code ..}
- * segments (path parameters stripped, as the container does), without
- * backslashes or control characters, and not under a reserved directory.
+ * segments in its raw or decoded form (path parameters stripped, as the
+ * container does), without backslashes, control characters, malformed or
+ * double escapes, and not under a reserved directory.
*
* @param path a context-relative path, optionally with a query string
* @return {@code true} if the path is safe to pass to a request dispatcher
@@ -43,10 +52,11 @@ public static boolean isSafeForwardPath(String path) {
}
int query = path.indexOf('?');
String uri = query == -1 ? path : path.substring(0, query);
- if (containsTraversal(uri)) {
+ String resolved = resolve(uri);
+ if (resolved == null) {
return false;
}
- String lower = uri.toLowerCase(Locale.ROOT);
+ String lower = resolved.toLowerCase(Locale.ROOT);
return !(lower.startsWith("/web-inf/") || lower.equals("/web-inf")
|| lower.startsWith("/meta-inf/") || lower.equals("/meta-inf"));
}
@@ -59,7 +69,24 @@ public static boolean isSafeForwardPath(String path) {
* @return {@code true} if the alias contains no traversal
*/
public static boolean isSafeMetaAlias(String metaAlias) {
- return metaAlias != null && !metaAlias.isEmpty() && !containsTraversal(metaAlias);
+ return metaAlias != null && !metaAlias.isEmpty() && resolve(metaAlias) != null;
+ }
+
+ /**
+ * The path as the container will resolve it, or {@code null} when it must not
+ * be dispatched to: traversal, a backslash or a control character in either
+ * the raw or the decoded form, or an escape that is malformed or survives the
+ * container's single decode.
+ */
+ private static String resolve(String value) {
+ if (containsTraversal(value)) {
+ return null;
+ }
+ String decoded = decodeOnce(value);
+ if (decoded == null || decoded.indexOf('%') != -1 || containsTraversal(decoded)) {
+ return null;
+ }
+ return decoded;
}
private static boolean containsTraversal(String value) {
@@ -81,4 +108,57 @@ private static boolean containsTraversal(String value) {
}
return false;
}
+
+ /**
+ * Percent-decodes {@code value} exactly once, the way the container does for
+ * a path: {@code %XX} with two ASCII hex digits only, {@code +} left alone.
+ *
+ * @return the decoded value, or {@code null} if an escape is malformed
+ */
+ private static String decodeOnce(String value) {
+ if (value.indexOf('%') == -1) {
+ return value;
+ }
+ StringBuilder decoded = new StringBuilder(value.length());
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int i = 0;
+ while (i < value.length()) {
+ char c = value.charAt(i);
+ if (c != '%') {
+ decoded.append(c);
+ i++;
+ continue;
+ }
+ // A run of escapes is one byte sequence: a multi-byte character has
+ // to be decoded as a whole or its bytes turn into replacement characters.
+ bytes.reset();
+ while (i < value.length() && value.charAt(i) == '%') {
+ if (i + 2 >= value.length()) {
+ return null;
+ }
+ int hi = hexDigit(value.charAt(i + 1));
+ int lo = hexDigit(value.charAt(i + 2));
+ if (hi < 0 || lo < 0) {
+ return null;
+ }
+ bytes.write((hi << 4) | lo);
+ i += 3;
+ }
+ decoded.append(new String(bytes.toByteArray(), StandardCharsets.UTF_8));
+ }
+ return decoded.toString();
+ }
+
+ private static int hexDigit(char c) {
+ if (c >= '0' && c <= '9') {
+ return c - '0';
+ }
+ if (c >= 'a' && c <= 'f') {
+ return c - 'a' + 10;
+ }
+ if (c >= 'A' && c <= 'F') {
+ return c - 'A' + 10;
+ }
+ return -1;
+ }
}
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
new file mode 100644
index 0000000000..571b6a86d6
--- /dev/null
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
@@ -0,0 +1,98 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package com.sun.identity.federation.common;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.RequestDispatcher;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Drives {@link FSUtils#forwardRequest} with a same-host target, the branch
+ * that ends in a {@code RequestDispatcher} rather than a redirect.
+ */
+public class FSUtilsForwardPathTest {
+
+ private static final String BASE = "http://sp.example.com:8080/openam";
+
+ private String savedDeploymentURI;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+
+ @BeforeClass
+ public void pinDeploymentUri() {
+ savedDeploymentURI = FSUtils.deploymentURI;
+ FSUtils.deploymentURI = "/openam";
+ }
+
+ @AfterClass(alwaysRun = true)
+ public void restoreDeploymentUri() {
+ FSUtils.deploymentURI = savedDeploymentURI;
+ }
+
+ @BeforeMethod
+ public void setUp() {
+ request = mock(HttpServletRequest.class);
+ when(request.getServerName()).thenReturn("sp.example.com");
+ when(request.getServerPort()).thenReturn(8080);
+ response = mock(HttpServletResponse.class);
+ }
+
+ @Test
+ public void forwardsAnInAppTargetOnTheSameHost() throws Exception {
+ RequestDispatcher dispatcher = mock(RequestDispatcher.class);
+ when(request.getRequestDispatcher("/UI/Login?goto=%2Fopenam%2Fconsole")).thenReturn(dispatcher);
+
+ FSUtils.forwardRequest(request, response, BASE + "/UI/Login?goto=%2Fopenam%2Fconsole");
+
+ verify(dispatcher).forward(request, response);
+ verify(response, never()).sendError(anyInt());
+ }
+
+ @DataProvider
+ public Object[][] traversingTargets() {
+ return new Object[][] {
+ {BASE + "/x/../WEB-INF/web.xml"},
+ {BASE + "/WEB-INF/web.xml"},
+ // The container decodes the dispatcher path before it normalises it.
+ {BASE + "/x/%2e%2e/WEB-INF/web.xml"},
+ {BASE + "/x/%252e%252e/WEB-INF/web.xml"},
+ {BASE + "/x/..%2fWEB-INF/web.xml"},
+ {BASE + "/%57EB-INF/web.xml"},
+ {BASE + "/x/%zz"},
+ };
+ }
+
+ @Test(dataProvider = "traversingTargets")
+ public void answers400WithoutDispatchingForATraversingTarget(String url) throws Exception {
+ FSUtils.forwardRequest(request, response, url);
+
+ verify(response).sendError(HttpServletResponse.SC_BAD_REQUEST);
+ verify(request, never()).getRequestDispatcher(anyString());
+ verify(response, never()).sendRedirect(anyString());
+ }
+}
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
index 4ebed81af7d0224f63a3ac12e3dcdd5b64ceaa0a..28497885b62cc66404053579076259fe70d2a447 100644
GIT binary patch
delta 1122
zcmZ`&%Wl&^6cuftt;@2oD4R44{(g|)2We$!O1+Fm35Vcp%Z8x3q$
zdE820-MBL*2zRMk6GCoayX*=v4?&TDaj@WKmJg)f$&!NQJg%}N$;7RYP{E}8ac(_%
z!wSym4%2gc`kcJzi*6W@h7`1(aRW5zJy&_Dnx4^1tv?nu#pkeh7ybrWbw%Gxq!qfz0{_Gj_WLqIp@|7_fI
zUHjtG+WAWQXJg}-?wLduMo#gbO>sy}$@)Zv?kh#_`O_0H{6Vy!Om~zv^2)S#{V)xz
w-z%qX`pdeV`kUKZ284JlMsa_=n7msBLFZG7+3qUE$zZM8qtV6L=GSZg01YTXQvd(}
delta 54
zcmZ3ewq0t&B9_TqtU8;|vp!;CWY}E6Q_VEFgNJ`|Kbz*{X8sq943iHEh)r%5kOGQo
KZ0;AF#0UTf+!A2`
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSSingleLogoutServletTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSSingleLogoutServletTest.java
new file mode 100644
index 0000000000..45996a8ab9
--- /dev/null
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSSingleLogoutServletTest.java
@@ -0,0 +1,90 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package com.sun.identity.federation.services.logout;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.RequestDispatcher;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Drives the unauthenticated {@code /liberty-logout} endpoint end to end: the
+ * request parameter goes in, the dispatcher path (or the error status) comes out.
+ */
+public class FSSingleLogoutServletTest {
+
+ private FSSingleLogoutServlet servlet;
+ private ServletContext servletContext;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+
+ @BeforeMethod
+ public void setUp() throws Exception {
+ servletContext = mock(ServletContext.class);
+ ServletConfig config = mock(ServletConfig.class);
+ when(config.getServletContext()).thenReturn(servletContext);
+ servlet = new FSSingleLogoutServlet();
+ servlet.init(config);
+ request = mock(HttpServletRequest.class);
+ response = mock(HttpServletResponse.class);
+ }
+
+ @Test
+ public void forwardsAnOrdinaryAliasToTheProcessLogoutHandler() throws Exception {
+ when(request.getParameter("metaAlias")).thenReturn("/idp");
+ RequestDispatcher dispatcher = mock(RequestDispatcher.class);
+ when(servletContext.getRequestDispatcher("/ProcessLogout/metaAlias/idp")).thenReturn(dispatcher);
+
+ servlet.doGet(request, response);
+
+ verify(dispatcher).forward(request, response);
+ verify(response, never()).sendError(anyInt(), anyString());
+ }
+
+ @DataProvider
+ public Object[][] traversingAliases() {
+ return new Object[][] {
+ {"/../../WEB-INF/web.xml"},
+ // getParameter() has already decoded one layer; the container decodes
+ // the dispatcher path once more before normalising it.
+ {"/%2e%2e/%2e%2e/WEB-INF/web.xml"},
+ {"/%252e%252e/%252e%252e/WEB-INF/web.xml"},
+ {"/idp/..%2f..%2fWEB-INF/web.xml"},
+ };
+ }
+
+ @Test(dataProvider = "traversingAliases")
+ public void answers400WithoutDispatchingForATraversingAlias(String metaAlias) throws Exception {
+ when(request.getParameter("metaAlias")).thenReturn(metaAlias);
+
+ servlet.doGet(request, response);
+
+ verify(response).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ verify(servletContext, never()).getRequestDispatcher(anyString());
+ }
+}
From e415dc54850f486a4abff93b28a8db3693bf5feb Mon Sep 17 00:00:00 2001
From: Valera V Harseko
Date: Thu, 17 Sep 2026 10:14:00 +0300
Subject: [PATCH 4/5] Collapse the forward path before the reserved-directory
check
Review round 3 on #1128:
- ForwardPathValidator ran the /WEB-INF check on the decoded but
uncollapsed path, while the container collapses "//", "/./" and
";params" before mapping: "//WEB-INF/web.xml", "/./WEB-INF/web.xml",
"/WEB-INF;x/web.xml" and "/%2e/WEB-INF/web.xml" passed and were served.
resolve() now returns the collapsed path and the check reads that; the
fragment-stripped form is checked too, since
HttpServletRequest.getRequestDispatcher() drops "#..." before mapping.
- An escape that decodes to a delimiter (%25, %3F, %23) or to invalid
UTF-8 (overlong %C0%AE) is refused; the container would map the first
literally and replace the second, neither of which is a path this code
means to reach.
- FSReturnLogoutServletTest drives the prefix-mapped servlet through the
raw getRequestURI() alias fallback; FSUtilsForwardPathTest pins the
collapsed forms and restores FSUtils.deploymentURI per test.
---
.../common/ForwardPathValidator.java | 89 ++++++++++++++----
.../common/FSUtilsForwardPathTest.java | 29 +++---
.../common/ForwardPathValidatorTest.java | 25 +++++
.../logout/FSReturnLogoutServletTest.java | 91 +++++++++++++++++++
4 files changed, 201 insertions(+), 33 deletions(-)
create mode 100644 openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSReturnLogoutServletTest.java
diff --git a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
index 6a53630cff..8ea68a44fa 100644
--- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
+++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
@@ -16,6 +16,9 @@
package com.sun.identity.federation.common;
import java.io.ByteArrayOutputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
@@ -26,11 +29,13 @@
* filters declared in {@code web.xml}, so a path an end user controls must never
* be allowed to climb out of the location the code intends to dispatch to.
*
- * The container percent-decodes a dispatcher path once more before it normalises
- * it (Tomcat: {@code dispatchersUseEncodedPaths}, on by default), so every check
- * runs on the decoded form as well as on the raw one, and a value that would
- * still carry an escape after that single decode - a second encoding layer - is
- * refused outright: no in-app path of the product needs one.
+ * The container percent-decodes a dispatcher path once more and then collapses
+ * it ({@code //}, {@code /./}, {@code ;params}) before it maps it (Tomcat:
+ * {@code dispatchersUseEncodedPaths}, on by default), so every check runs on the
+ * decoded form as well as on the raw one, and the reserved-directory check reads
+ * the collapsed path. An escape that decodes to a URL delimiter ({@code %25},
+ * {@code %3F}, {@code %23}) or to something that is not UTF-8 is refused
+ * outright: no in-app path of the product carries one.
*/
public final class ForwardPathValidator {
@@ -41,7 +46,7 @@ private ForwardPathValidator() {
* Whether {@code path} may be forwarded to as-is: absolute, without {@code ..}
* segments in its raw or decoded form (path parameters stripped, as the
* container does), without backslashes, control characters, malformed or
- * double escapes, and not under a reserved directory.
+ * delimiter escapes, and not under a reserved directory once collapsed.
*
* @param path a context-relative path, optionally with a query string
* @return {@code true} if the path is safe to pass to a request dispatcher
@@ -53,12 +58,21 @@ public static boolean isSafeForwardPath(String path) {
int query = path.indexOf('?');
String uri = query == -1 ? path : path.substring(0, query);
String resolved = resolve(uri);
- if (resolved == null) {
+ if (resolved == null || isReserved(resolved)) {
return false;
}
- String lower = resolved.toLowerCase(Locale.ROOT);
- return !(lower.startsWith("/web-inf/") || lower.equals("/web-inf")
- || lower.startsWith("/meta-inf/") || lower.equals("/meta-inf"));
+ // HttpServletRequest.getRequestDispatcher() drops a fragment before it
+ // maps the path, so the reserved-directory check has to see that form
+ // too; a ServletContext dispatcher keeps it, which the traversal checks
+ // above already cover.
+ int fragment = resolved.indexOf('#');
+ return fragment == -1 || !isReserved(collapse(resolved.substring(0, fragment)));
+ }
+
+ private static boolean isReserved(String collapsedPath) {
+ String lower = collapsedPath.toLowerCase(Locale.ROOT);
+ return lower.startsWith("/web-inf/") || lower.equals("/web-inf")
+ || lower.startsWith("/meta-inf/") || lower.equals("/meta-inf");
}
/**
@@ -73,20 +87,39 @@ public static boolean isSafeMetaAlias(String metaAlias) {
}
/**
- * The path as the container will resolve it, or {@code null} when it must not
- * be dispatched to: traversal, a backslash or a control character in either
- * the raw or the decoded form, or an escape that is malformed or survives the
- * container's single decode.
+ * The path as the container will map it - decoded once and collapsed - or
+ * {@code null} when it must not be dispatched to: traversal, a backslash or a
+ * control character in either the raw or the decoded form, or an escape that
+ * is malformed, not UTF-8, or decodes to a URL delimiter.
*/
private static String resolve(String value) {
if (containsTraversal(value)) {
return null;
}
String decoded = decodeOnce(value);
- if (decoded == null || decoded.indexOf('%') != -1 || containsTraversal(decoded)) {
+ if (decoded == null || containsTraversal(decoded)) {
return null;
}
- return decoded;
+ return collapse(decoded);
+ }
+
+ /**
+ * Collapses a path the way the container does before mapping it: {@code ;params}
+ * stripped from each segment, empty and {@code .} segments dropped. {@code ..}
+ * never reaches here.
+ */
+ private static String collapse(String path) {
+ StringBuilder collapsed = new StringBuilder(path.length());
+ for (String segment : path.split("/", -1)) {
+ int semicolon = segment.indexOf(';');
+ if (semicolon != -1) {
+ segment = segment.substring(0, semicolon);
+ }
+ if (!segment.isEmpty() && !segment.equals(".")) {
+ collapsed.append('/').append(segment);
+ }
+ }
+ return collapsed.length() == 0 ? "/" : collapsed.toString();
}
private static boolean containsTraversal(String value) {
@@ -113,7 +146,8 @@ private static boolean containsTraversal(String value) {
* Percent-decodes {@code value} exactly once, the way the container does for
* a path: {@code %XX} with two ASCII hex digits only, {@code +} left alone.
*
- * @return the decoded value, or {@code null} if an escape is malformed
+ * @return the decoded value, or {@code null} if an escape is malformed, is
+ * not valid UTF-8, or decodes to {@code %}, {@code ?} or {@code #}
*/
private static String decodeOnce(String value) {
if (value.indexOf('%') == -1) {
@@ -130,7 +164,7 @@ private static String decodeOnce(String value) {
continue;
}
// A run of escapes is one byte sequence: a multi-byte character has
- // to be decoded as a whole or its bytes turn into replacement characters.
+ // to be decoded as a whole.
bytes.reset();
while (i < value.length() && value.charAt(i) == '%') {
if (i + 2 >= value.length()) {
@@ -141,10 +175,25 @@ private static String decodeOnce(String value) {
if (hi < 0 || lo < 0) {
return null;
}
- bytes.write((hi << 4) | lo);
+ int b = (hi << 4) | lo;
+ if (b == '%' || b == '?' || b == '#') {
+ // A second encoding layer, or a delimiter the container would
+ // map literally: never a plain in-app path.
+ return null;
+ }
+ bytes.write(b);
i += 3;
}
- decoded.append(new String(bytes.toByteArray(), StandardCharsets.UTF_8));
+ try {
+ decoded.append(StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes.toByteArray())));
+ } catch (CharacterCodingException e) {
+ // Overlong or truncated sequences: the container would turn them
+ // into replacement characters, never into a path this code means.
+ return null;
+ }
}
return decoded.toString();
}
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
index 571b6a86d6..57216f76ab 100644
--- a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
@@ -25,8 +25,7 @@
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
-import org.testng.annotations.AfterClass;
-import org.testng.annotations.BeforeClass;
+import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -43,25 +42,23 @@ public class FSUtilsForwardPathTest {
private HttpServletRequest request;
private HttpServletResponse response;
- @BeforeClass
- public void pinDeploymentUri() {
- savedDeploymentURI = FSUtils.deploymentURI;
- FSUtils.deploymentURI = "/openam";
- }
-
- @AfterClass(alwaysRun = true)
- public void restoreDeploymentUri() {
- FSUtils.deploymentURI = savedDeploymentURI;
- }
-
@BeforeMethod
public void setUp() {
+ // forwardRequest reads the static deployment URI; pinned per test and
+ // restored whatever the outcome.
+ savedDeploymentURI = FSUtils.deploymentURI;
+ FSUtils.deploymentURI = "/openam";
request = mock(HttpServletRequest.class);
when(request.getServerName()).thenReturn("sp.example.com");
when(request.getServerPort()).thenReturn(8080);
response = mock(HttpServletResponse.class);
}
+ @AfterMethod(alwaysRun = true)
+ public void restoreDeploymentUri() {
+ FSUtils.deploymentURI = savedDeploymentURI;
+ }
+
@Test
public void forwardsAnInAppTargetOnTheSameHost() throws Exception {
RequestDispatcher dispatcher = mock(RequestDispatcher.class);
@@ -84,6 +81,12 @@ public Object[][] traversingTargets() {
{BASE + "/x/..%2fWEB-INF/web.xml"},
{BASE + "/%57EB-INF/web.xml"},
{BASE + "/x/%zz"},
+ // Forms the container collapses to /WEB-INF/web.xml before mapping.
+ {BASE + "//WEB-INF/web.xml"},
+ {BASE + "/./WEB-INF/web.xml"},
+ {BASE + "/WEB-INF;x/web.xml"},
+ {BASE + "/%2e/WEB-INF/web.xml"},
+ {BASE + "/WEB-INF#/x"},
};
}
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
index 28497885b6..fee68b6d5a 100644
--- a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
@@ -33,6 +33,10 @@ public Object[][] safeForwardPaths() {
{"/console/a+b.jsp"}, // '+' is literal in a path
{"/web-info/page.jsp"}, // only the real WEB-INF directory is reserved
{"/a/b..c/d"}, // ".." inside a segment is an ordinary name
+ {"/console//base/AMAdminFrame"}, // collapses to an ordinary path
+ {"/./console/base/AMAdminFrame"},
+ {"/console/base;jsessionid=1/AMAdminFrame"},
+ {"/XUI/#login/"}, // the container drops the fragment: /XUI/
};
}
@@ -72,6 +76,25 @@ public Object[][] unsafeForwardPaths() {
{"/x/%zz.jsp"},
{"/x/%2"},
{"/x/100%.jsp"},
+ // The container collapses "//", "/./" and ";params" before mapping,
+ // so the reserved-directory check has to see the collapsed path.
+ {"//WEB-INF/web.xml"},
+ {"/./WEB-INF/web.xml"},
+ {"/WEB-INF;x/web.xml"},
+ {"/;/WEB-INF/web.xml"},
+ {"/%2e/WEB-INF/web.xml"},
+ {"/WEB-INF/"},
+ {"/WEB-INF/./web.xml"},
+ // request.getRequestDispatcher() drops a fragment before mapping.
+ {"/WEB-INF#/x"},
+ {"/WEB-INF/web.xml#x"},
+ {"/x#/../WEB-INF/web.xml"}, // a ServletContext dispatcher keeps the fragment
+ // An escape that decodes to a URL delimiter is never a plain in-app path.
+ {"/WEB-INF%3fx/web.xml"},
+ {"/WEB-INF%23/web.xml"},
+ {"/x/\u007f.jsp"},
+ {"/x/%7f.jsp"},
+ {"/x/%c0%ae%c0%ae/WEB-INF/web.xml"}, // overlong UTF-8 is not a path
};
}
@@ -111,6 +134,8 @@ public Object[][] unsafeMetaAliases() {
{"/%252e%252e/%252e%252e/WEB-INF/web.xml"},
{"/idp%00"},
{"/idp%zz"},
+ {"/idp#/../../WEB-INF/web.xml"},
+ {"/idp/%c0%ae%c0%ae/WEB-INF/web.xml"},
};
}
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSReturnLogoutServletTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSReturnLogoutServletTest.java
new file mode 100644
index 0000000000..c1ff15dc21
--- /dev/null
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/services/logout/FSReturnLogoutServletTest.java
@@ -0,0 +1,91 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package com.sun.identity.federation.services.logout;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Drives {@code /ReturnLogout/*} through its second alias source: the servlet is
+ * prefix-mapped, so with no {@code metaAlias} parameter the alias is cut out of
+ * the raw, still-encoded {@code getRequestURI()}.
+ */
+public class FSReturnLogoutServletTest {
+
+ private FSReturnLogoutServlet servlet;
+ private ServletContext servletContext;
+ private HttpServletRequest request;
+ private HttpServletResponse response;
+
+ @BeforeMethod
+ public void setUp() throws Exception {
+ servletContext = mock(ServletContext.class);
+ ServletConfig config = mock(ServletConfig.class);
+ when(config.getServletContext()).thenReturn(servletContext);
+ servlet = new FSReturnLogoutServlet();
+ servlet.init(config);
+ request = mock(HttpServletRequest.class);
+ when(request.getParameter("metaAlias")).thenReturn(null);
+ response = mock(HttpServletResponse.class);
+ }
+
+ @Test
+ public void anOrdinaryAliasInTheRequestUriPassesTheGuard() throws Exception {
+ when(request.getRequestURI()).thenReturn("/openam/ReturnLogout/metaAlias/idp");
+
+ servlet.doGet(request, response);
+
+ // No session in a unit test: the servlet fails later, but not on the alias.
+ verify(response, never()).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ verify(response).sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), anyString());
+ }
+
+ @DataProvider
+ public Object[][] traversingRequestUris() {
+ return new Object[][] {
+ {"/openam/ReturnLogout/metaAlias/../../WEB-INF/web.xml"},
+ {"/openam/ReturnLogout/metaAlias/idp/..%2f..%2fWEB-INF/web.xml"},
+ {"/openam/ReturnLogout/metaAlias/%2e%2e/%2e%2e/WEB-INF/web.xml"},
+ {"/openam/ReturnLogout/metaAlias/%252e%252e/%252e%252e/WEB-INF/web.xml"},
+ };
+ }
+
+ @Test(dataProvider = "traversingRequestUris")
+ public void answers400WithoutDispatchingForATraversingAliasInTheRequestUri(String uri) throws Exception {
+ when(request.getRequestURI()).thenReturn(uri);
+
+ servlet.doGet(request, response);
+
+ verify(response).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ verify(response, never()).sendError(eq(HttpServletResponse.SC_INTERNAL_SERVER_ERROR), anyString());
+ verify(servletContext, never()).getRequestDispatcher(anyString());
+ verify(request, never()).getRequestDispatcher(anyString());
+ verify(response, never()).sendError(anyInt());
+ }
+}
From 53397347b8f553cc9974d962696e6a1593540e7b Mon Sep 17 00:00:00 2001
From: Valera V Harseko
Date: Thu, 17 Sep 2026 15:13:28 +0300
Subject: [PATCH 5/5] Strip path parameters from the raw forward path before
decoding it
Tomcat's ApplicationContext.getRequestDispatcher strips ";param" on the raw
string, from each ';' up to the next raw '/', and only then decodes and
normalises. The validator decoded first, so an encoded slash inside a
parameter had already become a separator when the parameter was cut per
segment: "/;%2Fjunk/WEB-INF/web.xml" read as "/junk/WEB-INF/web.xml" here
and as "/WEB-INF/web.xml" in the container. resolve() now strips path
parameters on the raw string in the container's order; a ';' produced by
an escape (%3B) is refused like the other delimiter escapes, since the
container maps it literally.
The fragment-stripped form that HttpServletRequest.getRequestDispatcher()
maps goes through resolve() as a whole, not only through the
reserved-directory check, so a trailing "..#" segment is refused. A '?'
in a meta alias is refused: the container cuts the dispatcher path there,
which turned "/..?" into a whole ".." segment one level up.
Pinned in ForwardPathValidatorTest (87 rows) and FSUtilsForwardPathTest
(18): the ";%2F" forms, "/x/..#", "/..#", "/x%3by/", "/..?", plus the two
round-2 forms that had no row ("/;x/", "/.;x/"). Twelve rows are red with
the previous validator.
---
.../common/ForwardPathValidator.java | 93 ++++++++++++-------
.../common/FSUtilsForwardPathTest.java | 7 ++
.../common/ForwardPathValidatorTest.java | 16 ++++
3 files changed, 85 insertions(+), 31 deletions(-)
diff --git a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
index 8ea68a44fa..5f23cd896a 100644
--- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
+++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java
@@ -29,12 +29,16 @@
* filters declared in {@code web.xml}, so a path an end user controls must never
* be allowed to climb out of the location the code intends to dispatch to.
*
- * The container percent-decodes a dispatcher path once more and then collapses
- * it ({@code //}, {@code /./}, {@code ;params}) before it maps it (Tomcat:
- * {@code dispatchersUseEncodedPaths}, on by default), so every check runs on the
- * decoded form as well as on the raw one, and the reserved-directory check reads
- * the collapsed path. An escape that decodes to a URL delimiter ({@code %25},
- * {@code %3F}, {@code %23}) or to something that is not UTF-8 is refused
+ * The container processes a dispatcher path in a fixed order before it maps it
+ * (Tomcat {@code ApplicationContext.getRequestDispatcher}, with
+ * {@code dispatchersUseEncodedPaths} on by default): the query string is cut,
+ * {@code ;params} are stripped from the raw string (each {@code ;} up to the
+ * next raw {@code /}), the rest is percent-decoded once, and {@code //} and
+ * {@code /./} are collapsed. The checks here follow that order: traversal is
+ * refused in the raw form and again in the decoded form, and the
+ * reserved-directory check reads the stripped, decoded, collapsed path. An
+ * escape that decodes to a URL delimiter ({@code %25}, {@code %3F},
+ * {@code %23}, {@code %3B}) or to something that is not UTF-8 is refused
* outright: no in-app path of the product carries one.
*/
public final class ForwardPathValidator {
@@ -62,11 +66,16 @@ public static boolean isSafeForwardPath(String path) {
return false;
}
// HttpServletRequest.getRequestDispatcher() drops a fragment before it
- // maps the path, so the reserved-directory check has to see that form
- // too; a ServletContext dispatcher keeps it, which the traversal checks
- // above already cover.
- int fragment = resolved.indexOf('#');
- return fragment == -1 || !isReserved(collapse(resolved.substring(0, fragment)));
+ // maps the path, so that form has to pass every check as well (a
+ // trailing "..#" segment is ".." once the fragment is gone); a
+ // ServletContext dispatcher keeps the fragment, which the checks
+ // above cover.
+ int fragment = uri.indexOf('#');
+ if (fragment != -1) {
+ String withoutFragment = resolve(uri.substring(0, fragment));
+ return withoutFragment != null && !isReserved(withoutFragment);
+ }
+ return true;
}
private static boolean isReserved(String collapsedPath) {
@@ -83,20 +92,28 @@ private static boolean isReserved(String collapsedPath) {
* @return {@code true} if the alias contains no traversal
*/
public static boolean isSafeMetaAlias(String metaAlias) {
- return metaAlias != null && !metaAlias.isEmpty() && resolve(metaAlias) != null;
+ // The container cuts the dispatcher path at the first '?', so a '?'
+ // in the alias would end the path early and turn a trailing ".." that
+ // precedes it into a whole segment; an alias never carries one.
+ return metaAlias != null && !metaAlias.isEmpty() && metaAlias.indexOf('?') == -1
+ && resolve(metaAlias) != null;
}
/**
- * The path as the container will map it - decoded once and collapsed - or
- * {@code null} when it must not be dispatched to: traversal, a backslash or a
- * control character in either the raw or the decoded form, or an escape that
- * is malformed, not UTF-8, or decodes to a URL delimiter.
+ * The path as the container will map it - path parameters stripped, decoded
+ * once, collapsed - or {@code null} when it must not be dispatched to:
+ * traversal, a backslash or a control character in either the raw or the
+ * decoded form, or an escape that is malformed, not UTF-8, or decodes to a
+ * URL delimiter.
*/
private static String resolve(String value) {
if (containsTraversal(value)) {
return null;
}
- String decoded = decodeOnce(value);
+ // The container strips ";param" on the raw string, up to the next raw
+ // '/', before it decodes: an encoded slash inside a parameter goes with
+ // the parameter and never becomes a separator.
+ String decoded = decodeOnce(stripPathParams(value));
if (decoded == null || containsTraversal(decoded)) {
return null;
}
@@ -104,17 +121,36 @@ private static String resolve(String value) {
}
/**
- * Collapses a path the way the container does before mapping it: {@code ;params}
- * stripped from each segment, empty and {@code .} segments dropped. {@code ..}
- * never reaches here.
+ * Drops every {@code ;param} the way the container does on the raw path:
+ * from each {@code ;} up to the next {@code /}.
+ */
+ private static String stripPathParams(String path) {
+ if (path.indexOf(';') == -1) {
+ return path;
+ }
+ StringBuilder stripped = new StringBuilder(path.length());
+ int pos = 0;
+ while (pos < path.length()) {
+ int semicolon = path.indexOf(';', pos);
+ if (semicolon == -1) {
+ stripped.append(path, pos, path.length());
+ break;
+ }
+ stripped.append(path, pos, semicolon);
+ int slash = path.indexOf('/', semicolon);
+ pos = slash == -1 ? path.length() : slash;
+ }
+ return stripped.toString();
+ }
+
+ /**
+ * Collapses a decoded, parameter-free path the way the container normalises
+ * it before mapping: empty and {@code .} segments dropped. {@code ..} never
+ * reaches here.
*/
private static String collapse(String path) {
StringBuilder collapsed = new StringBuilder(path.length());
for (String segment : path.split("/", -1)) {
- int semicolon = segment.indexOf(';');
- if (semicolon != -1) {
- segment = segment.substring(0, semicolon);
- }
if (!segment.isEmpty() && !segment.equals(".")) {
collapsed.append('/').append(segment);
}
@@ -130,11 +166,6 @@ private static boolean containsTraversal(String value) {
}
}
for (String segment : value.split("/", -1)) {
- // The container strips ";param" from each segment before normalising.
- int semicolon = segment.indexOf(';');
- if (semicolon != -1) {
- segment = segment.substring(0, semicolon);
- }
if (segment.equals("..")) {
return true;
}
@@ -147,7 +178,7 @@ private static boolean containsTraversal(String value) {
* a path: {@code %XX} with two ASCII hex digits only, {@code +} left alone.
*
* @return the decoded value, or {@code null} if an escape is malformed, is
- * not valid UTF-8, or decodes to {@code %}, {@code ?} or {@code #}
+ * not valid UTF-8, or decodes to {@code %}, {@code ?}, {@code #} or {@code ;}
*/
private static String decodeOnce(String value) {
if (value.indexOf('%') == -1) {
@@ -176,7 +207,7 @@ private static String decodeOnce(String value) {
return null;
}
int b = (hi << 4) | lo;
- if (b == '%' || b == '?' || b == '#') {
+ if (b == '%' || b == '?' || b == '#' || b == ';') {
// A second encoding layer, or a delimiter the container would
// map literally: never a plain in-app path.
return null;
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
index 57216f76ab..3b670c9112 100644
--- a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/FSUtilsForwardPathTest.java
@@ -85,8 +85,15 @@ public Object[][] traversingTargets() {
{BASE + "//WEB-INF/web.xml"},
{BASE + "/./WEB-INF/web.xml"},
{BASE + "/WEB-INF;x/web.xml"},
+ {BASE + "/;x/WEB-INF/web.xml"},
+ {BASE + "/.;x/WEB-INF/web.xml"},
{BASE + "/%2e/WEB-INF/web.xml"},
{BASE + "/WEB-INF#/x"},
+ // ";param" is stripped on the raw string, encoded slash included.
+ {BASE + "/;%2Fjunk/WEB-INF/web.xml"},
+ {BASE + "/;jsessionid=1%2Fa/WEB-INF/web.xml"},
+ // request.getRequestDispatcher() drops the fragment: /x/.. -> /
+ {BASE + "/x/..#"},
};
}
diff --git a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
index fee68b6d5a..b6dfb2bbd5 100644
--- a/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
+++ b/openam-federation/openam-federation-library/src/test/java/com/sun/identity/federation/common/ForwardPathValidatorTest.java
@@ -36,6 +36,7 @@ public Object[][] safeForwardPaths() {
{"/console//base/AMAdminFrame"}, // collapses to an ordinary path
{"/./console/base/AMAdminFrame"},
{"/console/base;jsessionid=1/AMAdminFrame"},
+ {"/console;x%2Fy/base/AMAdminFrame"}, // the parameter goes, encoded slash and all
{"/XUI/#login/"}, // the container drops the fragment: /XUI/
};
}
@@ -82,16 +83,28 @@ public Object[][] unsafeForwardPaths() {
{"/./WEB-INF/web.xml"},
{"/WEB-INF;x/web.xml"},
{"/;/WEB-INF/web.xml"},
+ {"/;x/WEB-INF/web.xml"},
+ {"/.;x/WEB-INF/web.xml"},
{"/%2e/WEB-INF/web.xml"},
{"/WEB-INF/"},
{"/WEB-INF/./web.xml"},
+ // The container strips ";param" on the raw string up to the next raw
+ // "/", before it decodes: an encoded slash inside the parameter goes
+ // with it, and the rest collapses to /WEB-INF/web.xml.
+ {"/;%2Fjunk/WEB-INF/web.xml"},
+ {"/;jsessionid=1%2Fa/WEB-INF/web.xml"},
+ {"/;x%2Fjunk/WEB-INF/web.xml"},
+ {"/;%2Fa%2Fb/WEB-INF/web.xml"},
// request.getRequestDispatcher() drops a fragment before mapping.
{"/WEB-INF#/x"},
{"/WEB-INF/web.xml#x"},
+ {"/x/..#"}, // ".." once the fragment is gone
+ {"/..#"},
{"/x#/../WEB-INF/web.xml"}, // a ServletContext dispatcher keeps the fragment
// An escape that decodes to a URL delimiter is never a plain in-app path.
{"/WEB-INF%3fx/web.xml"},
{"/WEB-INF%23/web.xml"},
+ {"/x%3by/WEB-INF/web.xml"},
{"/x/\u007f.jsp"},
{"/x/%7f.jsp"},
{"/x/%c0%ae%c0%ae/WEB-INF/web.xml"}, // overlong UTF-8 is not a path
@@ -116,6 +129,7 @@ public Object[][] safeMetaAliases() {
{"/myrealm/sp"},
{"/my-realm/my_sp.1"},
{"/my%20realm/sp"}, // raw request URI form of "/my realm/sp"
+ {"/idp;x/sp"}, // a path parameter is stripped, not traversed
};
}
@@ -129,6 +143,8 @@ public Object[][] unsafeMetaAliases() {
{"\\..\\..\\WEB-INF\\web.xml"},
{"/idp\0"},
{"/idp?x=/../WEB-INF"},
+ {"/..?"}, // the container cuts the path at '?': a whole ".."
+ {"/idp/..?x=1"},
{"/%2e%2e/%2e%2e/WEB-INF/web.xml"},
{"/idp/..%2f..%2fWEB-INF/web.xml"},
{"/%252e%252e/%252e%252e/WEB-INF/web.xml"},