From f9289b36b61e34d98c049313e75569f7ded65e68 Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Wed, 21 Oct 2026 08:53:14 +0000
Subject: [PATCH] GHSA-c5gp-3768-jc59 OAuth2 file token authorization lets an unauthenticated client select files outside the token directory

---
 opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolverTest.java |  127 ++++++++++++++++++++++++++++++++++++++++++
 opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties              |    3 +
 opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java     |   46 +++++++++++++--
 3 files changed, 170 insertions(+), 6 deletions(-)

diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java
index fc092f6..42163fb 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java
@@ -12,6 +12,7 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.rest2ldap.authz;
 
@@ -22,10 +23,12 @@
 import static org.forgerock.util.promise.Promises.newExceptionPromise;
 import static org.forgerock.util.promise.Promises.newResultPromise;
 
-import java.io.File;
-import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 
 import org.forgerock.http.oauth2.AccessTokenException;
 import org.forgerock.http.oauth2.AccessTokenInfo;
@@ -36,19 +39,30 @@
 import org.forgerock.services.context.Context;
 import org.forgerock.util.promise.Promise;
 
-/** A file access token resolver which should only be used for test purpose.*/
+/**
+ * A file access token resolver which should only be used for test purpose.
+ * <p>
+ * The token is the name of a file directly inside the token folder. The token arrives verbatim from the
+ * {@code Authorization} header of an unauthenticated request, so a token which is not a plain file name (it
+ * contains a path separator, or it resolves to a file outside the folder) is refused before any file is opened.
+ */
 final class FileAccessTokenResolver implements AccessTokenResolver {
 
-    private final String folderPath;
+    private final Path folder;
 
     FileAccessTokenResolver(final String folderPath) {
-        this.folderPath = checkNotNull(folderPath);
+        this.folder = Paths.get(checkNotNull(folderPath)).toAbsolutePath().normalize();
     }
 
     @Override
     public Promise<AccessTokenInfo, AccessTokenException> resolve(final Context context, final String token) {
+        final Path tokenFile = tokenFile(token);
+        if (tokenFile == null) {
+            return newExceptionPromise(newAccessTokenException(ERR_OAUTH2_FILE_INVALID_TOKEN_NAME.get(token)));
+        }
+
         final JsonValue accessToken;
-        try (final InputStream stream = new FileInputStream(new File(folderPath, token))) {
+        try (final InputStream stream = Files.newInputStream(tokenFile)) {
             accessToken = new JsonValue(Json.readJsonLenient(stream));
         } catch (final IOException e) {
             return newExceptionPromise(newAccessTokenException(ERR_OAUTH2_FILE_NO_TOKEN.get(token) , e));
@@ -64,4 +78,24 @@
                     newAccessTokenException(ERR_OAUTH2_FILE_INVALID_JSON_TOKEN.get(token, e.getMessage()), e));
         }
     }
+
+    /**
+     * Returns the file named by the token inside the token folder, or {@code null} when the token is not a plain
+     * file name. Separators are refused whatever the platform treats as one, and the resolved path must still have
+     * the folder as its parent and the token as its name once normalized, so that {@code .}, {@code ..}, an empty
+     * token or an absolute path never select anything outside the folder.
+     */
+    private Path tokenFile(final String token) {
+        if (token.indexOf('/') >= 0 || token.indexOf('\\') >= 0) {
+            return null;
+        }
+        final Path file;
+        try {
+            file = folder.resolve(token).normalize();
+        } catch (final InvalidPathException e) {
+            return null;
+        }
+        final Path name = file.getFileName();
+        return folder.equals(file.getParent()) && name != null && token.equals(name.toString()) ? file : null;
+    }
 }
diff --git a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
index 40631a1..a87847f 100644
--- a/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
+++ b/opendj-rest2ldap/src/main/resources/org/forgerock/opendj/rest2ldap/rest2ldap.properties
@@ -13,6 +13,7 @@
 #
 # Copyright 2016 ForgeRock AS.
 # Portions Copyright 2017 Rosie Applications, Inc.
+# Portions Copyright 2026 3A Systems, LLC.
 #
 
 # Configuration errors
@@ -51,6 +52,8 @@
 # Runtime errors > OAuth2 > File Resolver
 ERR_OAUTH2_FILE_NO_TOKEN_25=Unable to find token file '%s'
 ERR_OAUTH2_FILE_INVALID_JSON_TOKEN_26=Malformed token file '%s'. Details: '%s'
+ERR_OAUTH2_FILE_INVALID_TOKEN_NAME_92=Token '%s' is not the name of a file in the token directory: the token \
+  must be a plain file name without any path separator or parent directory reference
 # Runtime errors > OAuth2 > RFC-7662 Resolver
 ERR_OAUTH2_RFC7662_RETURNED_ERROR_27=Authorization server returned an error: '%s'
 ERR_OAUTH2_RFC7662_TOKEN_NOT_ACTIVE_28=Access token returned by authorization server is not currently active
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolverTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolverTest.java
new file mode 100644
index 0000000..5c15a46
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolverTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.opendj.rest2ldap.authz;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.forgerock.opendj.rest2ldap.authz.Authorization.newFileAccessTokenResolver;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+
+import org.forgerock.http.oauth2.AccessTokenException;
+import org.forgerock.http.oauth2.AccessTokenInfo;
+import org.forgerock.http.oauth2.AccessTokenResolver;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+/**
+ * The file resolver treats the bearer token as the name of a file inside the configured token directory. The
+ * token comes verbatim from the {@code Authorization} header of an unauthenticated request, so a token which is
+ * not a plain file name must never select a file elsewhere on the file system (GHSA-c5gp-3768-jc59).
+ */
+@Test
+@SuppressWarnings("javadoc")
+public class FileAccessTokenResolverTest extends ForgeRockTestCase {
+
+    private static final String VALID_TOKEN_JSON =
+            "{ \"scope\": [ \"read\" ], \"expireTime\": " + (System.currentTimeMillis() + 3_600_000L) + " }";
+
+    private Path root;
+    private Path tokenDir;
+    private Path outsideToken;
+    private AccessTokenResolver resolver;
+
+    @BeforeMethod
+    public void setUp() throws Exception {
+        root = Files.createTempDirectory("file-access-token-resolver");
+        tokenDir = Files.createDirectory(root.resolve("tokens"));
+        writeToken(tokenDir.resolve("valid-token"));
+        // A valid, unexpired token file one level above the token directory.
+        outsideToken = writeToken(root.resolve("outside-token"));
+        resolver = newFileAccessTokenResolver(tokenDir.toString());
+    }
+
+    @AfterMethod
+    public void tearDown() throws Exception {
+        Files.walk(root).sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete());
+    }
+
+    @Test
+    public void resolvesTokenFileByName() throws Exception {
+        final AccessTokenInfo token = resolver.resolve(null, "valid-token").getOrThrow();
+        assertThat(token.getToken()).isEqualTo("valid-token");
+        assertThat(token.getScopes()).containsExactly("read");
+    }
+
+    @Test
+    public void resolvesTokenNameContainingDots() throws Exception {
+        writeToken(tokenDir.resolve("header.payload..signature"));
+        final AccessTokenInfo token = resolver.resolve(null, "header.payload..signature").getOrThrow();
+        assertThat(token.getScopes()).containsExactly("read");
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsTokenEscapingTheDirectoryWithParentReferences() throws Exception {
+        assertThat(outsideToken).exists();
+        resolver.resolve(null, "../outside-token").getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsTokenEscapingTheDirectoryThroughAChildFirst() throws Exception {
+        resolver.resolve(null, "valid-token/../../outside-token").getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsAbsoluteTokenPath() throws Exception {
+        resolver.resolve(null, outsideToken.toAbsolutePath().toString()).getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsTokenNamingAFileInASubdirectory() throws Exception {
+        writeToken(Files.createDirectory(tokenDir.resolve("sub")).resolve("valid-token"));
+        resolver.resolve(null, "sub/valid-token").getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsTokenWithBackslashSeparator() throws Exception {
+        // Fails closed on every platform, whether or not the platform treats the backslash as a separator.
+        resolver.resolve(null, "..\\outside-token").getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsTokenNamingTheDirectoryItself() throws Exception {
+        resolver.resolve(null, ".").getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsEmptyToken() throws Exception {
+        resolver.resolve(null, "").getOrThrow();
+    }
+
+    @Test(expectedExceptions = AccessTokenException.class)
+    public void rejectsUnknownTokenFile() throws Exception {
+        resolver.resolve(null, "unknown-token").getOrThrow();
+    }
+
+    private static Path writeToken(final Path file) throws IOException {
+        return Files.write(file, VALID_TOKEN_JSON.getBytes(StandardCharsets.UTF_8));
+    }
+}

--
Gitblit v1.10.0