diff --git a/openam-authentication/openam-auth-oauth2/src/main/java/org/forgerock/openam/authentication/modules/oauth2/OAuth.java b/openam-authentication/openam-auth-oauth2/src/main/java/org/forgerock/openam/authentication/modules/oauth2/OAuth.java index a2952659f0..b785a39bd3 100644 --- a/openam-authentication/openam-auth-oauth2/src/main/java/org/forgerock/openam/authentication/modules/oauth2/OAuth.java +++ b/openam-authentication/openam-auth-oauth2/src/main/java/org/forgerock/openam/authentication/modules/oauth2/OAuth.java @@ -23,7 +23,7 @@ * "Portions Copyrighted [year] [name of copyright owner]" * * Portions Copyrighted 2015 Nomura Research Institute, Ltd. - * Portions Copyrighted 2018-2025 3A Systems, LLC. + * Portions Copyrighted 2018-2026 3A Systems, LLC. */ package org.forgerock.openam.authentication.modules.oauth2; @@ -224,7 +224,7 @@ public int process2(Callback[] callbacks, int state) throws LoginException { String ProviderLogoutURL = config.getLogoutServiceUrl(); - String csrfStateTokenId = RandomStringUtils.randomAlphanumeric(32); + String csrfStateTokenId = newCsrfStateTokenId(); String csrfState = createAuthorizationState(); Token csrfStateToken = new Token(csrfStateTokenId, TokenType.GENERIC); csrfStateToken.setAttribute(CoreTokenField.STRING_ONE, csrfState); @@ -521,6 +521,15 @@ public int process2(Callback[] callbacks, int state) throws LoginException { throw new AuthLoginException(BUNDLE_NAME, "unknownState", null); } + /** + * Id of the CTS record holding the CSRF state; it also travels in the + * NONCE_TOKEN_ID cookie, so it is drawn from a cryptographic generator rather + * than the java.util.Random behind RandomStringUtils.randomAlphanumeric(). + */ + static String newCsrfStateTokenId() { + return RandomStringUtils.random(32, 0, 0, true, true, null, random); + } + private String createAuthorizationState() { return UUID.randomUUID().toString(); //new BigInteger(160, new SecureRandom()).toString(Character.MAX_RADIX); } diff --git a/openam-authentication/openam-auth-oauth2/src/test/java/org/forgerock/openam/authentication/modules/oauth2/OAuthTest.java b/openam-authentication/openam-auth-oauth2/src/test/java/org/forgerock/openam/authentication/modules/oauth2/OAuthTest.java index 6bc55aacab..c8f6b78232 100644 --- a/openam-authentication/openam-auth-oauth2/src/test/java/org/forgerock/openam/authentication/modules/oauth2/OAuthTest.java +++ b/openam-authentication/openam-auth-oauth2/src/test/java/org/forgerock/openam/authentication/modules/oauth2/OAuthTest.java @@ -1,7 +1,24 @@ +/* + * 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 org.forgerock.openam.authentication.modules.oauth2; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotEquals; +import static org.testng.Assert.assertTrue; import java.util.Collections; import java.util.HashMap; @@ -29,5 +46,13 @@ public void shouldRemoveCredentialAndAccountStatusAttributesFromUpdates() { assertFalse(attributes.containsKey("userPassword")); assertFalse(attributes.containsKey("inetuserstatus")); } + + @Test + public void csrfStateTokenIdIsThirtyTwoAlphanumericCharacters() { + String id = OAuth.newCsrfStateTokenId(); + + assertTrue(id.matches("[A-Za-z0-9]{32}"), id); + assertNotEquals(id, OAuth.newCsrfStateTokenId()); + } } diff --git a/openam-core/src/main/java/com/sun/identity/idm/plugins/files/FilesRepo.java b/openam-core/src/main/java/com/sun/identity/idm/plugins/files/FilesRepo.java index f48ed80b2f..3f96a56bd5 100755 --- a/openam-core/src/main/java/com/sun/identity/idm/plugins/files/FilesRepo.java +++ b/openam-core/src/main/java/com/sun/identity/idm/plugins/files/FilesRepo.java @@ -25,6 +25,7 @@ * $Id: FilesRepo.java,v 1.22 2008/07/02 17:21:21 kenwho Exp $ * * Portions Copyrighted 2011-2016 ForgeRock AS. + * Portions Copyrighted 2026 3A Systems LLC. */ package com.sun.identity.idm.plugins.files; @@ -1510,7 +1511,18 @@ void initDir(String rootDir) throws IdRepoException { } - File constructFile(String rootDir, IdType type, String name) { + File constructFile(String rootDir, IdType type, String name) + throws IdRepoException { + // The identity name becomes the file name: it must stay a single path + // component, or an identity could be created, read or deleted anywhere + // the server can write. Control characters are refused too, so a name + // cannot forge a log line. The name is caller data and is not logged. + if (!isSingleFileName(name)) { + debug.error("FilesRepo.constructFile: invalid identity name of type " + + type.getName() + ", length " + (name == null ? 0 : name.length())); + throw new IdRepoException(IdRepoBundle.getString(IdRepoErrorCode.ILLEGAL_ARGUMENTS), + IdRepoErrorCode.ILLEGAL_ARGUMENTS); + } // Construct file name File root = new File(rootDir); File subDir = new File(root, type.getName()); @@ -1701,6 +1713,25 @@ boolean containsAttrValue(Set attrValues, Set patterns) { return (false); } + /** + * Whether an identity name is usable as one file name under the type + * directory: not empty, not a dot directory, and free of path separators + * (both kinds, so a repository copied between platforms stays valid) and + * of control characters. + */ + static boolean isSingleFileName(String name) { + if (name == null || name.isEmpty() || name.equals(".") || name.equals("..")) { + return false; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '/' || c == '\\' || c < ' ' || c == 0x7f) { + return false; + } + } + return true; + } + // File name filter inner class class FileRepoFileFilter implements FilenameFilter { // Pattern to match @@ -1709,13 +1740,25 @@ class FileRepoFileFilter implements FilenameFilter { // Default constructor FileRepoFileFilter(String p) { if (p != null && p.length() != 0 && !p.equals("*")) { - // Replace "*" with ".*" + // "*" is the only wildcard; everything between wildcards is a + // literal, so quote it rather than letting the search pattern + // be interpreted as a regular expression. + StringBuilder regex = new StringBuilder(); + int from = 0; int idx = p.indexOf('*'); while (idx != -1) { - p = p.substring(0, idx) + ".*" + p.substring(idx + 1); - idx = p.indexOf('*', idx + 2); + if (idx > from) { + regex.append(Pattern.quote(p.substring(from, idx))); + } + regex.append(".*"); + from = idx + 1; + idx = p.indexOf('*', from); + } + if (from < p.length()) { + regex.append(Pattern.quote(p.substring(from))); } - pattern = Pattern.compile(p.toLowerCase()); + pattern = Pattern.compile(regex.toString(), + Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); } } diff --git a/openam-core/src/test/java/com/sun/identity/idm/plugins/files/FilesRepoTest.java b/openam-core/src/test/java/com/sun/identity/idm/plugins/files/FilesRepoTest.java new file mode 100644 index 0000000000..22e7003217 --- /dev/null +++ b/openam-core/src/test/java/com/sun/identity/idm/plugins/files/FilesRepoTest.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.idm.plugins.files; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; + +import com.sun.identity.idm.IdRepoException; +import com.sun.identity.idm.IdType; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +public class FilesRepoTest { + + private final FilesRepo repo = new FilesRepo(); + + @Test + public void constructFileKeepsOrdinaryNamesUnderTheTypeDirectory() throws Exception { + File file = repo.constructFile("/var/openam/idRepo", IdType.USER, "demo.user-1"); + + assertThat(file).isEqualTo(new File(new File("/var/openam/idRepo", "user"), "demo.user-1")); + } + + @DataProvider + public Object[][] namesEscapingTheRepository() { + return new Object[][] { + {"../../../etc/passwd"}, + {".."}, + {"."}, + {"x/../../y"}, + {"sub/dir"}, + {"..\\..\\x"}, + {"evil\0"}, + {"evil\r\nforged"}, // CR/LF: log-line forging and never a valid name + {"tab\tname"}, + {""}, + {null}, + }; + } + + @Test(dataProvider = "namesEscapingTheRepository") + public void constructFileRejectsNamesThatLeaveTheTypeDirectory(String name) { + assertThatThrownBy(() -> repo.constructFile("/var/openam/idRepo", IdType.USER, name)) + .isInstanceOf(IdRepoException.class); + } + + @Test + public void fileFilterTreatsRegexMetaCharactersLiterally() { + FilesRepo.FileRepoFileFilter filter = repo.new FileRepoFileFilter("user.1"); + + assertThat(filter.accept(null, "user.1")).isTrue(); + assertThat(filter.accept(null, "userx1")).isFalse(); + } + + @Test + public void fileFilterStillExpandsWildcards() { + FilesRepo.FileRepoFileFilter filter = repo.new FileRepoFileFilter("us*r.1"); + + assertThat(filter.accept(null, "user.1")).isTrue(); + assertThat(filter.accept(null, "USER.1")).isTrue(); + assertThat(filter.accept(null, "uSomeThingr.1")).isTrue(); + assertThat(filter.accept(null, "userx1")).isFalse(); + assertThat(repo.new FileRepoFileFilter("*").accept(null, "anything")).isTrue(); + } + + @Test + public void fileFilterFoldsCaseBeyondAscii() { + FilesRepo.FileRepoFileFilter filter = repo.new FileRepoFileFilter("ЖОР*"); + + assertThat(filter.accept(null, "жора")).isTrue(); + assertThat(filter.accept(null, "Жора")).isTrue(); + assertThat(filter.accept(null, "жук")).isFalse(); + } + + @Test + public void fileFilterDoesNotInterpretInjectedRegex() { + // "(" would be a regex syntax error today; it must be a plain character. + FilesRepo.FileRepoFileFilter filter = repo.new FileRepoFileFilter("a(b"); + + assertThat(filter.accept(null, "a(b")).isTrue(); + assertThat(filter.accept(null, "ab")).isFalse(); + } +} diff --git a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/FSUtils.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/FSUtils.java index 8502f5d2a7..ce281b783a 100755 --- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/FSUtils.java +++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/FSUtils.java @@ -358,6 +358,16 @@ public static void forwardRequest( } else { String resource = newUrl.substring( index + deploymentURI.length()); + // The target comes from the request (goto/RelayState). A forward + // bypasses the web.xml filters and can reach /WEB-INF, so refuse + // anything that is not a plain in-app path. + if (!ForwardPathValidator.isSafeForwardPath(resource)) { + FSUtils.debug.warning("FSUtils.forwardRequest: refusing to " + + "forward to a path with traversal or a reserved " + + "directory"); + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } if (FSUtils.debug.messageEnabled()) { FSUtils.debug.message( "FSUtils.forwardRequest: Forwarding to :" + resource); 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 new file mode 100644 index 0000000000..8ea68a44fa --- /dev/null +++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/common/ForwardPathValidator.java @@ -0,0 +1,213 @@ +/* + * 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 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; + +/** + * Sanity checks for request-derived values before they are handed to a + * {@code RequestDispatcher}. A forward is served from inside the web application: + * 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 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 { + + 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 + * 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 + */ + public static boolean isSafeForwardPath(String path) { + if (path == null || !path.startsWith("/")) { + return false; + } + int query = path.indexOf('?'); + String uri = query == -1 ? path : path.substring(0, query); + String resolved = resolve(uri); + if (resolved == null || isReserved(resolved)) { + 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))); + } + + 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"); + } + + /** + * Whether {@code metaAlias} can be appended to a fixed handler path without + * changing which resource is dispatched to. + * + * @param metaAlias the provider meta alias taken from the request + * @return {@code true} if the alias contains no traversal + */ + public static boolean isSafeMetaAlias(String metaAlias) { + return metaAlias != null && !metaAlias.isEmpty() && 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. + */ + private static String resolve(String value) { + if (containsTraversal(value)) { + return null; + } + String decoded = decodeOnce(value); + if (decoded == null || containsTraversal(decoded)) { + return null; + } + 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) { + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '\\' || c < ' ' || c == 0x7f) { + return true; + } + } + 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; + } + } + 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, is + * not valid UTF-8, or decodes to {@code %}, {@code ?} or {@code #} + */ + 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. + 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; + } + 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; + } + 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(); + } + + 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/main/java/com/sun/identity/federation/services/logout/FSReturnLogoutServlet.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSReturnLogoutServlet.java index d348854052..83b0130a19 100644 --- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSReturnLogoutServlet.java +++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSReturnLogoutServlet.java @@ -37,6 +37,7 @@ import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import com.sun.identity.federation.common.ForwardPathValidator; import com.sun.identity.federation.common.FSUtils; import com.sun.identity.federation.common.IFSConstants; import com.sun.identity.federation.common.LogUtil; @@ -144,6 +145,15 @@ private void doGetPost(HttpServletRequest request, FSUtils.bundle.getString("aliasNotFound")); return; } + // The alias is appended to the dispatcher path below; a traversal in it + // would forward to an arbitrary resource of the web application. + if (!ForwardPathValidator.isSafeMetaAlias(providerAlias)) { + FSUtils.debug.error("FSReturnLogoutServlet: rejecting metaAlias " + + "with path traversal"); + response.sendError(response.SC_BAD_REQUEST, + FSUtils.bundle.getString("aliasNotFound")); + return; + } Object ssoToken = null; try { diff --git a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSSingleLogoutServlet.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSSingleLogoutServlet.java index b0969a81ab..c339bf222c 100755 --- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSSingleLogoutServlet.java +++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/federation/services/logout/FSSingleLogoutServlet.java @@ -24,7 +24,7 @@ * * $Id: FSSingleLogoutServlet.java,v 1.5 2008/12/19 06:50:47 exu Exp $ * - * Portions Copyrighted 2025 3A Systems LLC. + * Portions Copyrighted 2025-2026 3A Systems LLC. */ @@ -37,6 +37,7 @@ import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import com.sun.identity.federation.common.ForwardPathValidator; import com.sun.identity.federation.common.FSUtils; import com.sun.identity.federation.common.IFSConstants; import com.sun.identity.federation.services.util.FSServiceUtils; @@ -124,6 +125,15 @@ private void doGetPost(HttpServletRequest request, FSUtils.bundle.getString("aliasNotFound")); return; } + // The alias is appended to the dispatcher path below; a traversal in it + // would forward to an arbitrary resource of the web application. + if (!ForwardPathValidator.isSafeMetaAlias(providerAlias)) { + FSUtils.debug.error("FSSingleLogoutServlet: rejecting metaAlias " + + "with path traversal"); + response.sendError(response.SC_BAD_REQUEST, + FSUtils.bundle.getString("aliasNotFound")); + return; + } request.setAttribute("logoutSource", "local"); StringBuffer processLogout = new StringBuffer(); diff --git a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java index d4283dd4b7..95b0fea539 100644 --- a/openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java +++ b/openam-federation/openam-federation-library/src/main/java/com/sun/identity/saml/common/SAMLUtils.java @@ -63,6 +63,7 @@ import com.sun.identity.common.TaskRunnable; import com.sun.identity.common.TimerPool; import com.sun.identity.shared.xml.XMLUtils; +import org.apache.commons.lang3.StringEscapeUtils; import com.sun.identity.shared.encode.URLEncDec; import com.sun.identity.shared.encode.Base64; @@ -742,7 +743,10 @@ public static void postToTarget(HttpServletResponse response, PrintWriter out, } out.println("
\n"); } - out.println("