You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/10 14:50:30 UTC

(juneau) branch juneau-9.2.1-branch updated: Harden DirectoryResource file path resolution

This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch juneau-9.2.1-branch
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/juneau-9.2.1-branch by this push:
     new b91bce1617 Harden DirectoryResource file path resolution
b91bce1617 is described below

commit b91bce161713998fc9953b713263cd894af2931d
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Aug 10 10:50:08 2026 -0400

    Harden DirectoryResource file path resolution
    
    Resolve requested paths against a canonicalized root and reject paths that
    resolve outside it. Adds a regression test covering the rejected cases.
---
 .../org/apache/juneau/commons/utils/FileUtils.java |  70 +++++
 .../microservice/resources/DirectoryResource.java  |  27 +-
 .../DirectoryResource_PathTraversal_Test.java      | 306 +++++++++++++++++++++
 3 files changed, 397 insertions(+), 6 deletions(-)

diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/FileUtils.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/FileUtils.java
index 5887c5eab9..76be28de8c 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/FileUtils.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/FileUtils.java
@@ -24,6 +24,7 @@ import static org.apache.juneau.commons.utils.Utils.*;
 
 import java.io.*;
 import java.nio.file.*;
+import java.util.*;
 
 /**
  * File utilities.
@@ -31,6 +32,75 @@ import java.nio.file.*;
  */
 public class FileUtils {
 
+	// Shared message text — kept identical across boundary-check sites so attackers cannot
+	// distinguish "rejected by pre-existence check" from "rejected by symlink check".
+	private static final String MSG_pathEscape = "Path escapes configured root directory.";
+
+	/**
+	 * Resolves a user-supplied path against a configured root directory, rejecting any path that
+	 * escapes the root via {@code ..} segments, absolute paths, or out-of-root symlinks.
+	 *
+	 * <p>
+	 * Both {@code rootDir} and the resolved target are canonicalized via {@link Path#toRealPath()}
+	 * (with {@link Path#normalize()}) so symlinks are handled deterministically, then
+	 * {@code target.startsWith(root)} is asserted before returning.
+	 *
+	 * <p>
+	 * Boundary violation always wins over pre-existence: a path that escapes the root is rejected
+	 * with {@link IllegalArgumentException} (callers should map to {@code 403 Forbidden}), while a
+	 * non-existent in-root target returns {@link Optional#empty()} (callers should map to
+	 * {@code 404 Not Found}).
+	 *
+	 * @param rootDir The configured root directory. Must not be <jk>null</jk>.
+	 * @param userPath The user-supplied path relative to {@code rootDir}. May be <jk>null</jk> or
+	 * 	empty, in which case the resolved root itself is returned.
+	 * @return The resolved {@link File} if it exists inside {@code rootDir}, else empty.
+	 * @throws IllegalArgumentException If the resolved target escapes {@code rootDir}, or the path
+	 * 	string is invalid. Callers should map to {@code 403 Forbidden}.
+	 */
+	public static Optional<File> resolveSafely(File rootDir, String userPath) {
+		assertArgNotNull("rootDir", rootDir);
+		var root = canonicalizeRoot(rootDir.toPath());
+		if (userPath == null || userPath.isEmpty())
+			return Optional.of(root.toFile());
+		Path target;
+		try {
+			target = root.resolve(userPath).normalize();
+		} catch (@SuppressWarnings("unused") InvalidPathException e) {
+			throw new IllegalArgumentException(MSG_pathEscape);
+		}
+		if (! target.startsWith(root))
+			throw new IllegalArgumentException(MSG_pathEscape);
+		var f = target.toFile();
+		if (! f.exists())
+			return Optional.empty();
+		try {
+			if (! target.toRealPath().startsWith(root))
+				throw new IllegalArgumentException(MSG_pathEscape);
+		} catch (@SuppressWarnings("unused") NoSuchFileException e) {
+			return Optional.empty();
+		} catch (IOException e) {
+			throw new RuntimeException("Could not canonicalize '" + target + "'", e);
+		}
+		return Optional.of(f);
+	}
+
+	/**
+	 * Resolves {@code rootDir} via {@link Path#toRealPath()} so a symlinked root is normalized once,
+	 * falling back to {@link Path#toAbsolutePath()} + {@link Path#normalize()} if the directory does
+	 * not exist at call time.
+	 *
+	 * @param rootDir The root directory path.
+	 * @return The canonicalized root path.
+	 */
+	private static Path canonicalizeRoot(Path rootDir) {
+		try {
+			return rootDir.toRealPath();
+		} catch (@SuppressWarnings("unused") IOException e) {
+			return rootDir.toAbsolutePath().normalize();
+		}
+	}
+
 	/**
 	 * Creates a file if it doesn't already exist using {@link File#createNewFile()}.
 	 *
diff --git a/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/resources/DirectoryResource.java b/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/resources/DirectoryResource.java
index 7a3b98a42b..66e1da257d 100755
--- a/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/resources/DirectoryResource.java
+++ b/juneau-microservice/juneau-microservice-core/src/main/java/org/apache/juneau/microservice/resources/DirectoryResource.java
@@ -26,6 +26,7 @@ import java.util.*;
 
 import org.apache.juneau.annotation.*;
 import org.apache.juneau.bean.*;
+import org.apache.juneau.commons.utils.FileUtils;
 import org.apache.juneau.config.*;
 import org.apache.juneau.html.annotation.*;
 import org.apache.juneau.http.annotation.*;
@@ -312,13 +313,27 @@ public class DirectoryResource extends BasicRestServlet {
 			throw new Forbidden("Could not delete file {0}", f.getAbsolutePath());
 	}
 
+	/**
+	 * Resolves a request path against {@link #rootDir} with a canonical-path boundary check so
+	 * {@code ..} segments, absolute paths, and symlink-out cannot escape the configured root.
+	 *
+	 * <p>
+	 * Applied at this single funnel because every public operation on this resource (view,
+	 * download, delete, upload, info) resolves the user-supplied path through this method.
+	 * Delegates to {@link FileUtils#resolveSafely(File, String)}: boundary violation &rarr; 403
+	 * (Forbidden), non-existent target &rarr; 404 (NotFound).
+	 *
+	 * @param path The user-supplied path relative to the root directory.
+	 * @return The resolved file.
+	 * @throws NotFound If the resolved file does not exist inside the root directory.
+	 */
 	private File getFile(String path) throws NotFound {
-		if (path == null)
-			return rootDir;
-		var f = new File(rootDir.getAbsolutePath() + '/' + path);
-		if (f.exists())
-			return f;
-		throw new NotFound("File not found.");
+		try {
+			return FileUtils.resolveSafely(rootDir, path)
+				.orElseThrow(() -> new NotFound("File not found."));
+		} catch (IllegalArgumentException e) {
+			throw new Forbidden(e.getMessage());
+		}
 	}
 
 	/**
diff --git a/juneau-utest/src/test/java/org/apache/juneau/microservice/resources/DirectoryResource_PathTraversal_Test.java b/juneau-utest/src/test/java/org/apache/juneau/microservice/resources/DirectoryResource_PathTraversal_Test.java
new file mode 100644
index 0000000000..e89fc771ac
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/microservice/resources/DirectoryResource_PathTraversal_Test.java
@@ -0,0 +1,306 @@
+/*
+ * 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.juneau.microservice.resources;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assumptions.*;
+
+import java.io.*;
+import java.nio.file.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.config.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.io.*;
+
+/**
+ * Regression tests for path-traversal hardening in {@link DirectoryResource#getFile(String)}.
+ *
+ * <p>
+ * The pre-fix implementation did {@code new File(rootDir.getAbsolutePath() + '/' + path)} and only
+ * verified {@link File#exists()} — so {@code ..} segments, absolute paths, and symlinks pointing
+ * outside the configured root could all read files the JVM had access to. Each test below asserts
+ * the post-fix behavior: paths that escape the configured root are rejected with 403 (Forbidden);
+ * paths that stay inside the root continue to work.
+ *
+ * <p>
+ * The funnel for every public operation on this resource (view, download, delete, upload, info) is
+ * the private {@code getFile(String)} method, so the fix is applied once and verified across each
+ * operation surface here.
+ *
+ * @since 9.2.1
+ */
+class DirectoryResource_PathTraversal_Test extends TestBase {
+
+	@TempDir
+	static Path tempDir;
+
+	static Path rootDir;
+	static Path outsideSecret;
+	static Path symlinkInside;
+	static Path symlinkEscape;
+
+	@BeforeAll
+	static void setup() throws Exception {
+		rootDir = tempDir.resolve("dir-root");
+		Files.createDirectories(rootDir);
+		Files.writeString(rootDir.resolve("inside.txt"), "INSIDE_ROOT");
+
+		var nestedDir = rootDir.resolve("a/b");
+		Files.createDirectories(nestedDir);
+		Files.writeString(nestedDir.resolve("nested.txt"), "NESTED_INSIDE");
+
+		// File OUTSIDE the configured root — what the path-traversal attack tries to read.
+		outsideSecret = tempDir.resolve("outside-secret.txt");
+		Files.writeString(outsideSecret, "AUDIT_OUTSIDE_SECRET");
+
+		// File OUTSIDE the configured root that the upload-traversal attack tries to overwrite/create.
+		Files.writeString(tempDir.resolve("outside-upload-target.txt"), "ORIGINAL_OUTSIDE");
+
+		// Best-effort symlinks. Skip via assumption on filesystems that don't support them.
+		try {
+			symlinkInside = rootDir.resolve("link-to-inside.txt");
+			Files.createSymbolicLink(symlinkInside, rootDir.resolve("inside.txt"));
+		} catch (UnsupportedOperationException | IOException e) {
+			symlinkInside = null;
+		}
+		try {
+			symlinkEscape = rootDir.resolve("link-to-outside");
+			Files.createSymbolicLink(symlinkEscape, outsideSecret);
+		} catch (UnsupportedOperationException | IOException e) {
+			symlinkEscape = null;
+		}
+	}
+
+	/**
+	 * Test resource with a no-arg constructor so {@link MockRestClient} can instantiate it via
+	 * reflection. Points at the test's {@link #rootDir} and enables every operation so the boundary
+	 * check is exercised on each surface (view, download, delete, upload, info).
+	 */
+	@Rest(
+		allowedMethodParams="*"
+	)
+	public static class TestDirResource extends DirectoryResource {
+		private static final long serialVersionUID = 1L;
+
+		public TestDirResource() throws Exception {
+			super(buildConfig());
+		}
+
+		private static Config buildConfig() {
+			var cfg = Config.create().memStore().build();
+			cfg.set(DIRECTORY_RESOURCE_rootDir, rootDir.toString());
+			cfg.set(DIRECTORY_RESOURCE_allowViews, "true");
+			cfg.set(DIRECTORY_RESOURCE_allowUploads, "true");
+			cfg.set(DIRECTORY_RESOURCE_allowDeletes, "true");
+			return cfg;
+		}
+	}
+
+	@SuppressWarnings({
+		"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+	})
+	private static MockRestClient buildClient() {
+		return MockRestClient.buildLax(TestDirResource.class);
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Baseline — non-traversing requests still work
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@SuppressWarnings({
+		"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+	})
+	@Test void t01_normalAccess_view() throws Exception {
+		try (var c = buildClient()) {
+			c.request("VIEW", "/inside.txt").run()
+				.assertStatus(200)
+				.assertContent().is("INSIDE_ROOT");
+		}
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// direct ../ traversal across each operation surface (view, download, delete)
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void t02_directTraversal_GET_returns403() throws Exception {
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.get("/../outside-secret.txt").run().getStatusCode();
+			assertEquals(403, status, "GET /../outside-secret.txt must be rejected (path escapes root)");
+		}
+	}
+
+	@Test void t03_methodVIEW_traversal_returns403() throws Exception {
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var resp = c.get("/../outside-secret.txt?method=VIEW").run();
+			assertEquals(403, resp.getStatusCode(), "GET /../outside-secret.txt?method=VIEW must be rejected");
+			assertFalse(resp.getContent().asString().contains("AUDIT_OUTSIDE_SECRET"),
+				"Response body must not leak the outside-root secret");
+		}
+	}
+
+	@Test void t04_methodDOWNLOAD_traversal_returns403() throws Exception {
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var resp = c.get("/../outside-secret.txt?method=DOWNLOAD").run();
+			assertEquals(403, resp.getStatusCode(), "GET /../outside-secret.txt?method=DOWNLOAD must be rejected");
+			assertFalse(resp.getContent().asString().contains("AUDIT_OUTSIDE_SECRET"),
+				"Response body must not leak the outside-root secret");
+		}
+	}
+
+	@Test void t05_verbVIEW_traversal_returns403() throws Exception {
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.request("VIEW", "/../outside-secret.txt").run().getStatusCode();
+			assertEquals(403, status, "VIEW /../outside-secret.txt must be rejected");
+		}
+	}
+
+	@Test void t06_verbDOWNLOAD_traversal_returns403() throws Exception {
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.request("DOWNLOAD", "/../outside-secret.txt").run().getStatusCode();
+			assertEquals(403, status, "DOWNLOAD /../outside-secret.txt must be rejected");
+		}
+	}
+
+	@Test void t07_nestedTraversal_returns403() throws Exception {
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.get("/a/b/../../../outside-secret.txt").run().getStatusCode();
+			assertEquals(403, status, "GET /a/b/../../../outside-secret.txt must be rejected");
+		}
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Absolute path / URL-encoded variants
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void t08_absolutePathInRequest_isNotTreatedAsFilesystemAbsolute() throws Exception {
+		// "/etc/passwd" arrives as a relative segment under root → 404 (no such file), or 403 on
+		// platforms where it resolves absolute. Either is a non-leak outcome.
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.get("/etc/passwd").run().getStatusCode();
+			assertTrue(status == 403 || status == 404, "Status must be 403 or 404, was: " + status);
+		}
+	}
+
+	@Test void t09_urlEncodedTraversal_doesNotLeak() throws Exception {
+		// Container/HttpClient may URL-decode before our handler, or reject "%2e%2e" outright. Either
+		// is acceptable as long as the outside-root secret is NOT returned.
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var resp = c.get("/%2e%2e/outside-secret.txt").run();
+			assertFalse(resp.getContent().asString().contains("AUDIT_OUTSIDE_SECRET"),
+				"URL-encoded traversal must not leak the outside-root secret. Status was: " + resp.getStatusCode());
+			assertNotEquals(200, resp.getStatusCode(),
+				"URL-encoded traversal must not return 200. Status was: " + resp.getStatusCode());
+		}
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Upload / delete traversal (uploads + deletes enabled in TestDirResource)
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@Test void t10_uploadTraversal_does_not_create_outside_root() throws Exception {
+		var outsideUpload = tempDir.resolve("outside-uploaded-by-test.txt");
+		assertFalse(Files.exists(outsideUpload), "Pre-condition: upload target must not exist");
+
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.put("/../outside-uploaded-by-test.txt", "ATTACK_PAYLOAD").run().getStatusCode();
+			// Post-fix: boundary check fires first → 403 (or 404 legacy "must exist before PUT").
+			// Either way, the outside file must NOT be created.
+			assertTrue(status == 403 || status == 404,
+				"PUT to outside-root path must be rejected with 403/404, was: " + status);
+			assertFalse(Files.exists(outsideUpload),
+				"Outside-root file must NOT be created by PUT traversal");
+		}
+	}
+
+	@Test void t11_deleteTraversal_does_not_delete_outside_root() throws Exception {
+		assertTrue(Files.exists(outsideSecret), "Pre-condition: outside secret must exist");
+
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var status = c.delete("/../outside-secret.txt").run().getStatusCode();
+			assertEquals(403, status, "DELETE /../outside-secret.txt must be rejected with 403");
+			assertTrue(Files.exists(outsideSecret),
+				"Outside-root file must NOT be deleted by DELETE traversal");
+			assertEquals("AUDIT_OUTSIDE_SECRET", Files.readString(outsideSecret),
+				"Outside-root file content must be unchanged after DELETE traversal attempt");
+		}
+	}
+
+	//-----------------------------------------------------------------------------------------------------------------
+	// Symlink handling
+	//-----------------------------------------------------------------------------------------------------------------
+
+	@SuppressWarnings({
+		"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+	})
+	@Test void t12_symlinkInsideRoot_is_followed() throws Exception {
+		assumeTrue(symlinkInside != null, "Filesystem does not support symbolic links");
+
+		try (var c = buildClient()) {
+			c.request("VIEW", "/link-to-inside.txt").run()
+				.assertStatus(200)
+				.assertContent().is("INSIDE_ROOT");
+		}
+	}
+
+	@Test void t13_symlinkEscapesRoot_is_rejected() throws Exception {
+		assumeTrue(symlinkEscape != null, "Filesystem does not support symbolic links");
+
+		try (var c = buildClient()) {
+			@SuppressWarnings({
+				"resource"  // Closeable resources in tests are intentionally unassigned; closing is handled by test infrastructure.
+			})
+			var resp = c.request("VIEW", "/link-to-outside").run();
+			assertEquals(403, resp.getStatusCode(),
+				"Symlink to outside-root must be rejected with 403 (post-existence boundary check)");
+			assertFalse(resp.getContent().asString().contains("AUDIT_OUTSIDE_SECRET"),
+				"Symlink-escape response must not leak the outside-root secret");
+		}
+	}
+}