Skip to content

Add PathFence#805

Draft
garydgregory wants to merge 4 commits into
apache:masterfrom
garydgregory:path_fence
Draft

Add PathFence#805
garydgregory wants to merge 4 commits into
apache:masterfrom
garydgregory:path_fence

Conversation

@garydgregory

@garydgregory garydgregory commented Oct 19, 2025

Copy link
Copy Markdown
Member

A Path fence guards against using paths outside of a "fence" of made of root paths.

This was extracted from Apache Commons Text's private PathFence.

Thanks for your contribution to Apache Commons! Your help is appreciated!

Before you push a pull request, review this list:

  • Read the contribution guidelines for this project.
  • Read the ASF Generative Tooling Guidance if you use Artificial Intelligence (AI).
  • I used AI to create any part of, or all of, this pull request: The initial unit test was created and then heavily and manually modified using Co-Pilot.
  • Run a successful build using the default Maven goal with mvn; that's mvn on the command line by itself.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied. This may not always be possible, but it is a best-practice.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body. Note that a maintainer may squash commits during the merge process.

A Path fence guards against using paths outside of a "fence" of made of
root paths.
@garydgregory

Copy link
Copy Markdown
Member Author

@ppkarwasz
Any thoughts?

@ppkarwasz

Copy link
Copy Markdown
Member

This API needs a clear security model: is it intended for reading existing files, creating new files or both?

The path resolution strategy should change depending on the use case:

  1. Reading trusted configuration files
    When reading from trusted system locations (e.g. configuration directories managed by sysadmins), we typically must allow symlinks. Example: On Debian, CATALINA_BASE is /var/lib/tomcat, but /var/lib/tomcat/conf is a symlink to /etc/tomcat, which should be allowed.

  2. Reading files from untrusted user input
    For user-supplied paths, following symlinks can enable path traversal attacks. In this case, symlinks should generally not be followed.

At the moment, the class performs only syntactic path validation using path.toAbsolutePath().normalize(). This is sufficient for trusted environments (e.g. system-managed configuration directories), but it does not call Path.toRealPath(), which resolves symlinks. As a result, when used with a root directory that may contain untrusted content, it can be bypassed by symlink-based path traversal attacks.

Comment thread src/main/java/org/apache/commons/io/file/PathFence.java Outdated
@garydgregory

Copy link
Copy Markdown
Member Author

Hi @ppkarwasz
Thank you for the review. I've addressed the relative path issues. I'll look into symbolic links next.

@Marcono1234 Marcono1234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully these comments are useful, and not disruptive. Though I understand that this PR is still a draft and the design and implementation decisions on this PR are not yet final.

*/
// Cannot implement both UnaryOperator<Path> and Function<String, Path>
public Path apply(final String fileName) {
return getPath(fileName, Paths.get(fileName));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Usage of Paths ties this to the default file system. Is that intended (if so would be good to document it), or would it make more sense to ensure that all roots have the same file system and then use root.getFileSystem().getPath(...)? (would only work though if roots is non-empty)

Comment on lines +165 to +168
final Optional<Path> first = roots.stream().filter(pathAbs::startsWith).findFirst();
if (first.isPresent()) {
return path;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Stream#anyMatch instead of stream.findFirst().isPresent()?

Comment on lines +161 to +163
if (roots.isEmpty()) {
return path;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if roots is empty, the path fence is effectively disabled?

Maybe would be better to add an explicit Builder method / static PathFence factory method for that instead? Otherwise when accidentally not calling setRoots or calling it with an empty array, it might give a false sense of security when PathFence is actually ineffective.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this should fail securely

}

@ParameterizedTest
@ValueSource(strings = { "/a/b", "/a/b/c", "/a/b/c/d", "a", "a/b", "a/b/c", "a/b/c/d" })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also cover "" or "/" to ensure #805 (comment) is fixed?

*
* @since 2.21.0
*/
// Cannot implement both UnaryOperator<Path> and Function<String, Path>, so don't pick one over the other

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about a predicate instead?

* @throws IllegalArgumentException Thrown if the path is not within our fence.
*/
// Cannot implement both UnaryOperator<Path> and Function<String, Path>
public Path apply(final Path path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find apply() a bit non descriptive, and why does it return a Path instead of a boolean? Not sure if IAE should be thrown as a result of a policy check.

return getPath(fileName, Paths.get(fileName));
}

private Path getPath(final String fileName, final Path path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetPath is a strange name for this, how about “isInsideFence” or something like that? Also why does it have two argumente, one of it unused?

if (first.isPresent()) {
return path;
}
throw new IllegalArgumentException(String.format("[%s] -> [%s] not in the fence %s", fileName, pathAbs, roots));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s typically frowned upon to disclose system infos (like resolved absolute path) in exceptions, at least in the toString/getMessage

final Path relPath = relPathTop.resolve("foo/bar");
assertSame(relPath, fence.apply(relPath));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably also define what to do with \ separator and test for windows ans linux machines.

Should it also deny \0 bytes before using Path API?

final Path relPath = relPathTop.resolve("foo/bar");
assertSame(relPath, fence.apply(relPath));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it should probably also describe if or if not how syml8nks are handled (toRealPath?)

* @return The given path.
* @throws IllegalArgumentException Thrown if the file name is not within our fence.
*/
// Cannot implement both UnaryOperator<Path> and Function<String, Path>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is pretty confusing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants