diff --git a/src/main/java/org/apache/commons/io/file/PathFence.java b/src/main/java/org/apache/commons/io/file/PathFence.java new file mode 100644 index 00000000000..2a0554fee14 --- /dev/null +++ b/src/main/java/org/apache/commons/io/file/PathFence.java @@ -0,0 +1,171 @@ +/* + * 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 + * + * https://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.commons.io.file; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +/** + * A Path fence guards against using paths outside of a "fence" of made of root paths. + *

+ * For example, to prevent an application from using paths outside of its configuration folder: + *

+ *
+ * Path config = Paths.get("path/to/config");
+ * PathFence fence = PathFence.builder().setRoots(config).get();
+ * 
+ *

+ * You call one of the {@code apply} methods to validate paths in your application: + *

+ *
+ * // Gets a Path or throws IllegalArgumentException
+ * Path file1 = fence.{@link #apply(String) apply}("someName");
+ * Path file2 = fence.{@link #apply(Path) apply}(somePath);
+ * 
+ *

+ * You can also use multiple roots as the path fence: + *

+ *
+ * Path globalConfig = Paths.get("path1/to/global-config");
+ * Path userConfig = Paths.get("path2/to/user-config");
+ * Path localConfig = Paths.get("path3/to/sys-config");
+ * PathFence fence = PathFence.builder().setRoots(globalConfig, userConfig, localConfig).get();
+ * 
+ *

+ * To use the current directory as the path fence: + *

+ *
+ * PathFence fence = PathFence.builder().setRoots(PathUtils.current()).get();
+ * 
+ * + * @since 2.21.0 + */ +// Cannot implement both UnaryOperator and Function, so don't pick one over the other +public final class PathFence { + + /** + * Builds {@link PathFence} instances. + */ + public static final class Builder implements Supplier { + + /** The empty Path array. */ + private static final Path[] EMPTY = {}; + + /** + * A fence is made of root Paths. + */ + private Path[] roots = EMPTY; + + /** + * Constructs a new instance. + */ + private Builder() { + // empty + } + + @Override + public PathFence get() { + return new PathFence(this); + } + + /** + * Sets the paths that delineate this fence. + * + * @param roots the paths that delineate this fence. + * @return {@code this} instance. + */ + Builder setRoots(final Path... roots) { + this.roots = roots != null ? roots.clone() : EMPTY; + return this; + } + } + + /** + * Creates a new builder. + * + * @return a new builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A fence is made of Paths guarding Path resolution. + */ + private final List roots; + + /** + * Constructs a new instance. + * + * @param builder A builder. + */ + private PathFence(final Builder builder) { + this.roots = Arrays.stream(builder.roots).map(this::absoluteNormalize).collect(Collectors.toList()); + } + + /** + * Converts the given path to a normalized absolute path. + * + * @param path The source path. + * @return The result path. + */ + private Path absoluteNormalize(final Path path) { + return path.toAbsolutePath().normalize(); + } + + /** + * Checks that that a Path is within our fence. + * + * @param path The path to test. + * @return The given path. + * @throws IllegalArgumentException Thrown if the path is not within our fence. + */ + // Cannot implement both UnaryOperator and Function + public Path apply(final Path path) { + return getPath(path.toString(), path); + } + + /** + * Gets a Path for the given file name, checking that it is within our fence. + * + * @param fileName the file name to test. + * @return The given path. + * @throws IllegalArgumentException Thrown if the file name is not within our fence. + */ + // Cannot implement both UnaryOperator and Function + public Path apply(final String fileName) { + return getPath(fileName, Paths.get(fileName)); + } + + private Path getPath(final String fileName, final Path path) { + if (roots.isEmpty()) { + return path; + } + final Path pathAbs = absoluteNormalize(path); + final Optional first = roots.stream().filter(pathAbs::startsWith).findFirst(); + if (first.isPresent()) { + return path; + } + throw new IllegalArgumentException(String.format("[%s] -> [%s] not in the fence %s", fileName, pathAbs, roots)); + } +} diff --git a/src/test/java/org/apache/commons/io/file/PathFenceTest.java b/src/test/java/org/apache/commons/io/file/PathFenceTest.java new file mode 100644 index 00000000000..0ca0eef0a67 --- /dev/null +++ b/src/test/java/org/apache/commons/io/file/PathFenceTest.java @@ -0,0 +1,144 @@ +/* + * 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 + * + * https://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.commons.io.file; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link PathFence}. + */ +public class PathFenceTest { + + private Path createDirectory(final Path tempDir, final String other) throws IOException { + return Files.createDirectory(tempDir.resolve(other)); + } + + private Path getRelPathToTop() { + final Path startPath = PathUtils.current().toAbsolutePath(); + final Path parent = startPath; + final int nameCount = parent.getNameCount(); + final String relName = StringUtils.repeat("../", nameCount); + final Path relPath = Paths.get(relName); + // sanity checks + final Path rootPath = relPath.toAbsolutePath().normalize(); + assertNull(rootPath.getFileName()); + assertEquals(startPath.getRoot(), rootPath); + return relPath; + } + + @Test + void testAbsolutePath(@TempDir final Path fenceRootPath) throws Exception { + // tempDir is the fence + final Path resolved = fenceRootPath.resolve("child/file.txt"); + final PathFence fence = PathFence.builder().setRoots(fenceRootPath).get(); + // getPath with an absolute string should be allowed + final Path childOk = fence.apply(resolved.toString()); + assertEquals(resolved.toAbsolutePath().normalize(), childOk.toAbsolutePath().normalize()); + // check with a Path instance should return the same instance when allowed + assertSame(resolved, fence.apply(resolved)); + } + + @ParameterizedTest + @ValueSource(strings = { "", ".", "some", "some/relative", "some/relative/path" }) + void testEmpty(final String test) { + // An empty fence accepts anything + final PathFence fence = PathFence.builder().get(); + final Path path = Paths.get(test); + assertEquals(path, fence.apply(test)); + assertSame(path, fence.apply(path)); + } + + @ParameterizedTest + @ValueSource(strings = { "/a/b", "/a/b/c", "/a/b/c/d", "a", "a/b", "a/b/c", "a/b/c/d" }) + public void testEscapeAttempt(final Path fenceRootPath) { + final Path resolved = fenceRootPath.resolve("../../etc/passwd"); + final Path relative = Paths.get("../../etc/passwd"); + final PathFence fence = PathFence.builder().setRoots(fenceRootPath).get(); + assertThrows(IllegalArgumentException.class, () -> fence.apply(resolved)); + assertThrows(IllegalArgumentException.class, () -> fence.apply(relative)); + assertThrows(IllegalArgumentException.class, () -> fence.apply(resolved.toString())); + assertThrows(IllegalArgumentException.class, () -> fence.apply(relative.toString())); + } + + @Test + void testMultipleFencePaths(@TempDir final Path tempDir) throws Exception { + // The fence is inside tempDir + final Path fenceRootPath1 = createDirectory(tempDir, "root-one"); + final Path fenceRootPath2 = createDirectory(tempDir, "root-two"); + final PathFence fence = PathFence.builder().setRoots(fenceRootPath1, fenceRootPath2).get(); + // Path under the first path should be allowed + final Path inPath1 = fenceRootPath1.resolve("a/b.txt"); + assertSame(inPath1, fence.apply(inPath1)); + // Path under the second path should be allowed + final Path inPath2 = fenceRootPath2.resolve("a/b.txt"); + assertSame(inPath2, fence.apply(inPath2)); + } + + @Test + void testNormalization(@TempDir final Path tempDir) throws Exception { + final Path fenceRootPath = createDirectory(tempDir, "root-one"); + final Path resolved = fenceRootPath.resolve("subdir/../other.txt"); + final PathFence fence = PathFence.builder().setRoots(fenceRootPath).get(); + assertSame(resolved, fence.apply(resolved)); + } + + @Test + void testOutsideFenceThrows(@TempDir final Path tempDir) throws Exception { + final Path fenceRootPath = createDirectory(tempDir, "root-one"); + final Path other = createDirectory(tempDir, "other"); + final PathFence fence = PathFence.builder().setRoots(fenceRootPath).get(); + final IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> fence.apply(other.toString())); + final String msg = ex.getMessage(); + assertNotNull(msg); + assertTrue(msg.contains("not in the fence"), () -> "Expected message to mention fence: " + msg); + assertTrue(msg.contains(other.toAbsolutePath().toString()), () -> "Expected message to contain the path: " + msg); + } + + @Test + void testResolveRelative() throws Exception { + final PathFence fence = PathFence.builder().setRoots(Paths.get("/foo/bar")).get(); + final Path relPathTop = getRelPathToTop(); + final Path relPath = relPathTop.resolve("foo/bar"); + assertSame(relPath, fence.apply(relPath)); + } + + @Test + void testResolveRelativeRoot() throws Exception { + final Path relPathTop = getRelPathToTop(); + final PathFence fence = PathFence.builder().setRoots(relPathTop.resolve("foo/bar")).get(); + final Path relPath = relPathTop.resolve("foo/bar"); + assertSame(relPath, fence.apply(relPath)); + } + +}