Skip to content

Conversation

@franferrax
Copy link
Contributor

@franferrax franferrax commented Apr 5, 2025

Hi, this is a proposal to fix 8352728.

The main idea is to replace java.nio.file.Path::toRealPath by java.io.File::getCanonicalPath for path canonicalization purposes. The rationale behind this decision is the following:

  1. In Windows, File::getCanonicalPath handles restricted permissions in parent directories. Contrarily, Path::toRealPath fails with AccessDeniedException.
  2. In Linux, File::getCanonicalPath handles non-regular files (e.g. /dev/stdin). Contrarily, Path::toRealPath fails with NoSuchFileException.

Windows Case

@martinuy and I tracked down the File::getCanonicalPath vs Path::toRealPath behaviour differences in Windows. Both methods end up calling the FindFirstFileW API inside a loop for each parent directory in the path, until they include the leaf:

NOTE: In cases in which File::getCanonicalPath gives a partially normalized path due to lack of permissions, the impact on cycle detection should be negligible: any include that leads to infinite recursion will revisit the exact same path at some point (even if not normalized).

Testing

The proposed ConfigFileTestDirPermissions test is passing, and no regressions have been found in test/jdk/java/security/Security/ConfigFileTest.java (Windows and Linux).

Also, the GitHub Actions testing run (tier1 on various platforms) has passed, and I've repeated the #16483 tested categories:

  • test/jdk/java/security/Security
  • test/jdk/javax/net/ssl/compatibility
  • test/jdk/java/security/Provider/SecurityProviderModularTest.java
  • test/jdk/javax/crypto/CryptoPermissions/CryptoPolicyFallback.java
Results

Linux:

java/security/Provider/SecurityProviderModularTest.java               Passed. Execution successful
java/security/Security/CaseInsensitiveAlgNames.java                   Passed. Execution successful
java/security/Security/ClassLoader/DeprivilegedModuleLoaderTest.java  Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/ClassLoaderDeadlock.java   Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/Deadlock.java              Passed. Execution successful
java/security/Security/ConfigFileTest.java                            Passed. Execution successful
java/security/Security/NoInstalledProviders.java                      Passed. Execution successful
java/security/Security/Nulls.java                                     Passed. Execution successful
java/security/Security/ProviderFiltering.java                         Passed. Execution successful
java/security/Security/SecurityPropFile/SecurityPropFile.java         Passed. Execution successful
java/security/Security/SynchronizedAccess.java                        Passed. Execution successful
java/security/Security/removing/RemoveProviderByIdentity.java         Passed. Execution successful
java/security/Security/removing/RemoveProviders.java                  Passed. Execution successful
java/security/Security/removing/RemoveStaticProvider.java             Passed. Execution successful
java/security/Security/signedfirst/DynStatic.java                     Passed. Execution successful
javax/crypto/CryptoPermissions/CryptoPolicyFallback.java              Passed. Execution successful
javax/net/ssl/compatibility/AlpnTest.java                             Passed. Execution successful
javax/net/ssl/compatibility/BasicConnectTest.java                     Passed. Execution successful
javax/net/ssl/compatibility/ClientHelloProcessing.java                Passed. Execution successful
javax/net/ssl/compatibility/HrrTest.java                              Passed. Execution successful
javax/net/ssl/compatibility/SniTest.java                              Passed. Execution successful

Windows

java/security/Provider/SecurityProviderModularTest.java               Passed. Execution successful
java/security/Security/CaseInsensitiveAlgNames.java                   Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/ClassLoaderDeadlock.java   Passed. Execution successful
java/security/Security/ClassLoaderDeadlock/Deadlock.java              Passed. Execution successful
java/security/Security/ClassLoader/DeprivilegedModuleLoaderTest.java  Passed. Execution successful
java/security/Security/ConfigFileTest.java                            Passed. Execution successful
java/security/Security/ConfigFileTestDirPermissions.java              Passed. Execution successful
java/security/Security/NoInstalledProviders.java                      Passed. Execution successful
java/security/Security/Nulls.java                                     Passed. Execution successful
java/security/Security/ProviderFiltering.java                         Passed. Execution successful
java/security/Security/SecurityPropFile/SecurityPropFile.java         Passed. Execution successful
java/security/Security/SynchronizedAccess.java                        Passed. Execution successful
java/security/Security/removing/RemoveProviderByIdentity.java         Passed. Execution successful
java/security/Security/removing/RemoveProviders.java                  Passed. Execution successful
java/security/Security/removing/RemoveStaticProvider.java             Passed. Execution successful
java/security/Security/signedfirst/DynStatic.java                     Passed. Execution successful
javax/crypto/CryptoPermissions/CryptoPolicyFallback.java              Passed. Execution successful
javax/net/ssl/compatibility/AlpnTest.java                             Passed. Execution successful
javax/net/ssl/compatibility/BasicConnectTest.java                     Passed. Execution successful
javax/net/ssl/compatibility/ClientHelloProcessing.java                Passed. Execution successful
javax/net/ssl/compatibility/HrrTest.java                              Passed. Execution successful
javax/net/ssl/compatibility/SniTest.java                              Passed. Execution successful

Testing Appendix

Originally, I could not make a fully automated symlinks resolution test in Windows, so I had posted here a PowerShell extended version of ConfigFileTestDirPermissions. Since directory junctions do not require elevation, the Java test now uses them to create soft-links and perform an equivalent test (but instead of linking files, we link directories). I will not delete the PowerShell test, just to keep a record.

ConfigFileTestDirPermissionsEx PowerShell test

This test requires user interaction, to accept UAC elevation when creating the symlink. To run it, just paste the whole snippet in a non-elevated PowerShell terminal at the root of a built jdk repository.

function ConfigFileTestDirPermissionsEx {
    # Ensures java.security is loaded and symlinks are resolved in Windows,
    # even when the user does not have permissions on a parent directory.

    # Make sure we run non-elevated
    $user = [Security.Principal.WindowsIdentity]::GetCurrent()
    $adminRole = [Security.Principal.WindowsBuiltInRole]::Administrator
    $principal = New-Object Security.Principal.WindowsPrincipal($user)
    if ($principal.IsInRole($adminRole)) {
        throw "Must run non-elevated!"
    }

    $originalJdk = Get-Item -ErrorAction SilentlyContinue "build/*/images/jdk"
    # Make sure a built JDK image is found
    if (![System.IO.Directory]::Exists($originalJdk.FullName)) {
        throw "Could not find a built image, must run from the jdk repo root"
    }

    # Create temporary directory
    $tempDirName = "JDK-8352728-tmp-" + (New-Guid).ToString("N")
    $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) $tempDirName
    New-Item $tempDir -ItemType Directory | Out-Null

    try {
        # Copy the jdk to a different directory
        $jdk = Join-Path $tempDir "jdk-parent-dir/jdk"
        Copy-Item -Recurse $originalJdk $jdk

        # Create an extra.properties file with a relative include in it
        $include = Join-Path $tempDir "relatively.included.properties"
        $testProperty = "test.property.name=test_property_value"
        Out-File -Encoding ascii $include -InputObject $testProperty
        $extra = Join-Path $tempDir "extra.properties"
        $content = "include " + (Split-Path -Leaf $include)
        Out-File -Encoding ascii $extra -InputObject $content

        # Create a symlink to extra.properties, from the jdk directory
        $mainPropsDir = Join-Path $jdk "conf/security"
        $mainProps = Join-Path $mainPropsDir "java.security"
        $link = Join-Path $mainPropsDir "link.to.extra.properties"
        Start-Process -Wait -Verb RunAs -WindowStyle Hidden "cmd.exe" @(
            "/c", "mklink", $link, $extra
        )

        # Include link.to.extra.properties from java.security
        $content = "`ninclude " + (Split-Path -Leaf $link)
        Out-File -Encoding ascii -Append $mainProps -InputObject $content

        # Remove current user permissions from jdk-parent-dir
        $parent = Split-Path -Parent $jdk
        $newAcl = New-Object System.Security.AccessControl.DirectorySecurity
        $newAcl.SetAccessRule((New-Object `
            System.Security.AccessControl.FileSystemAccessRule(
                $user.Name, "FullControl", "Deny"
            )
        ))
        $originalAcl = Get-Acl $parent
        Set-Acl $parent $newAcl

        try {
            # Make sure the permissions are affecting the current user
            $java = Join-Path $jdk "bin/java.exe"
            $stderrFile = Join-Path $tempDir "StandardError.txt"
            $realPath = Join-Path $tempDir "RealPath.java"
            Out-File -Encoding ascii $realPath -InputObject @"
            public final class RealPath {
                public static void main(String[] args) throws Exception {
                    java.nio.file.Path.of(args[0]).toRealPath();
                }
            }
"@
            $proc = Start-Process -Wait -WindowStyle Hidden -PassThru `
                                  -RedirectStandardError $stderrFile $java @(
                $realPath, $mainProps
            )
            $stderrContent = Get-Content $stderrFile
            if ($proc.ExitCode -eq 0) {
                throw "Directory should affect the user, expected to fail"
            }
            if (($stderrContent -match "AccessDeniedException").Length -eq 0) {
                throw "Failure was not an AccessDeniedException"
            }

            # Execute the copied jdk, ensuring java.security.Security is
            # loaded (i.e. use -XshowSettings:security:properties)
            $proc = Start-Process -Wait -WindowStyle Hidden -PassThru `
                                  -RedirectStandardError $stderrFile $java @(
                "-Djava.security.debug=properties",
                "-XshowSettings:security:properties",
                "-version"
            )
            $stderrContent = Get-Content $stderrFile
            Write-Output $stderrContent
            if ($proc.ExitCode -ne 0) {
                throw "Execution failed"
            }
            if (($stderrContent -match $testProperty).Length -eq 0) {
                throw "Expected '$testProperty' property not found"
            }
            Write-Output "TEST PASS - OK"
        } finally {
            Set-Acl $parent $originalAcl
        }
    } finally {
        Remove-Item -Recurse -Force $tempDir
    }
}

ConfigFileTestDirPermissionsEx

Progress

  • Change must be properly reviewed (1 review required, with at least 1 Reviewer)
  • Change must not contain extraneous whitespace
  • Commit message must refer to an issue

Issue

  • JDK-8352728: InternalError loading java.security due to Windows parent folder permissions (Bug - P3)

Reviewing

Using git

Checkout this PR locally:
$ git fetch https://git.openjdk.org/jdk.git pull/24465/head:pull/24465
$ git checkout pull/24465

Update a local copy of the PR:
$ git checkout pull/24465
$ git pull https://git.openjdk.org/jdk.git pull/24465/head

Using Skara CLI tools

Checkout this PR locally:
$ git pr checkout 24465

View PR using the GUI difftool:
$ git pr show -t 24465

Using diff file

Download this PR as a diff file:
https://git.openjdk.org/jdk/pull/24465.diff

Using Webrev

Link to Webrev Comment

@bridgekeeper
Copy link

bridgekeeper bot commented Apr 5, 2025

👋 Welcome back fferrari! A progress list of the required criteria for merging this PR into master will be added to the body of your pull request. There are additional pull request commands available for use with this pull request.

@openjdk
Copy link

openjdk bot commented Apr 5, 2025

❗ This change is not yet ready to be integrated.
See the Progress checklist in the description for automated requirements.

@openjdk
Copy link

openjdk bot commented Apr 5, 2025

@franferrax The following label will be automatically applied to this pull request:

  • security

When this pull request is ready to be reviewed, an "RFR" email will be sent to the corresponding mailing list. If you would like to change these labels, use the /label pull request command.

@openjdk openjdk bot added the security security-dev@openjdk.org label Apr 5, 2025
@franferrax franferrax marked this pull request as ready for review April 10, 2025 00:21
@openjdk openjdk bot added the rfr Pull request is ready for review label Apr 10, 2025
@mlbridge
Copy link

mlbridge bot commented Apr 10, 2025

@AlanBateman
Copy link
Contributor

AlanBateman commented Apr 10, 2025

I don't think this change should be integrated before more investigation. I think start by finding out why this code is using toRealPath. For the two cases listed, it looks like toRealPath is correctly failing for the first, but for /dev/stdin then please bring it to nio-dev to discuss how special devices should be handled by that method.

@franferrax
Copy link
Contributor Author

Hi @AlanBateman.

I don't think this change should be integrated before more investigation.

Ok, makes sense.

I think start by finding out why this code is using toRealPath.

The usage of Path::toRealPath was introduced by the 8319332: Security properties files inclusion proposal for the following reasons:

  1. Weak reason: detect cyclic re-inclusion through an alias of the same file (e.g. symlink, or alternative case in case-insensitive filesystems). This would only make cycle detection trigger earlier, but is not strictly necessary (infinite recursion will lead to path repetition even if not normalized).
  2. Weak reason: resolve a relative path passed through -Djava.security.properties=relative.props against the current working directory (stack: loadAll, loadExtra, loadExtraHelper, loadExtraFromPath, loadFromPath). This resolution could be done with Path::toAbsolutePath, but this case is also subject to the 3ʳᵈ reason.
  3. Strong reason: resolve symlinks, so that properties files use their original path to resolve relative includes. The rationale behind this is that the writer of the original properties file is the one who reasoned where relative includes should resolve to. On the other hand, the writer of the symlink just wants to use the original file with all its includes, without having to replicate anything else. This case is exercised by the PowerShell test on this PR's description.

For the two cases listed, it looks like toRealPath is correctly failing for the first […]

Yes, that was our impression, and that's why we are not proposing any fix to Path::toRealPath: there's nothing wrong with failing if normalization is not complete. But File::getCanonicalPath takes a best-effort approach that is more suitable to our needs.

[…] but for /dev/stdin then please bring it to nio-dev to discuss how special devices should be handled by that method.

I will investigate the Linux case, I had skipped it because File::getCanonicalPath looked like the only alternative on Windows, while it is also working on Linux.

@franferrax
Copy link
Contributor Author

Hi again @AlanBateman,

I've been doing some research on Linux, debugging the following sample:

GetContentAndRealPath.java

import java.nio.file.Files;
import java.nio.file.Path;

import static java.nio.charset.StandardCharsets.UTF_8;

public final class GetContentAndRealPath {
    public static void main(String[] args) throws Exception {
        if (args.length != 1) {
            throw new Exception("Please specify a file path");
        }
        Path path = Path.of(args[0]);

        System.out.println("%(java) Content:");
        System.out.println("%(java) => " +
                new String(Files.readAllBytes(path), UTF_8).trim());

        System.out.println("%(java)");
        System.out.println("%(java) File::getCanonicalPath:");
        System.out.println("%(java) => " +
                path.toFile().getCanonicalFile().toPath());

        System.out.println("%(java)");
        System.out.println("%(java) Path::toRealPath:");
        System.out.println("%(java) => " + path.toRealPath());
    }
}

The first thing to note is that /dev/stdin is not a problem per se, the actual problem is when it is provided by an anonymous pipe. So the following works fine:

$ java GetContentAndRealPath.java /dev/stdin </dev/null
%(java) Content:
%(java) => 
%(java)
%(java) File::getCanonicalPath:
%(java) => /dev/null
%(java)
%(java) Path::toRealPath:
%(java) => /dev/null

In Bash, there are various ways to provide stdin through an anonymous pipe:

# https://www.gnu.org/software/bash/manual/bash.html#Pipelines
echo Pipelines | java GetContentAndRealPath.java /dev/stdin

# https://www.gnu.org/software/bash/manual/bash.html#Here-Strings
java GetContentAndRealPath.java /dev/./stdin <<<Here-Strings

# https://www.gnu.org/software/bash/manual/bash.html#Here-Documents
java GetContentAndRealPath.java /etc/../dev/stdin <<EOF
Here-Documents
EOF

# https://www.gnu.org/software/bash/manual/bash.html#Process-Substitution
java GetContentAndRealPath.java <(echo Process-Substitution)

Here-Documents example:

$ java GetContentAndRealPath.java /etc/../dev/stdin <<EOF
Here-Documents
EOF
%(java) Content:
%(java) => Here-Documents
%(java)
%(java) File::getCanonicalPath:
%(java) => /dev/stdin
%(java)
%(java) Path::toRealPath:
Exception in thread "main" java.nio.file.NoSuchFileException: /etc/../dev/stdin
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixPath.toRealPath(UnixPath.java:834)
	at GetContentAndRealPath.main(GetContentAndRealPath.java:24)

Please note how File::getCanonicalPath resolves /etc/../dev/stdin/dev/stdin while Path::toRealPath fails with NoSuchFileException.

glibc's realpath()

I traced this behaviour inside glibc's realpath(), with the following GDB script:

gdb -q --nx java <<'EOF' 2>&1 | grep --color=never '^%(\w*)\|^Exception\|^	at'
# Settings
set debuginfod enabled on
set breakpoint pending on
handle SIGSEGV nostop noprint pass

# Break once at JDK_Canonicalize(), if the path starts with /dev/fd/
tbreak JDK_Canonicalize if ((int) strncmp(orig, "/dev/fd/", 8)) == 0

# Start java with a Process-Substitution anonymous pipe
run GetContentAndRealPath.java <(echo Process-Substitution)

# Stopped at JDK_Canonicalize() add a trace for each glibc's realpath() call
# https://github.com/bminor/glibc/blob/glibc-2.39/stdlib/canonicalize.c#L431
break canonicalize.c:431
commands
  python begin_realpath()
  continue
end

# Inside glibc's realpath() implementation, also trace each readlink() result
# https://github.com/bminor/glibc/blob/glibc-2.39/stdlib/canonicalize.c#L311
break canonicalize.c:311
commands
  python after_readlink()
  continue
end

############################################################
python
from errno import errorcode

def begin_realpath():
    name = gdb.parse_and_eval('name').string('utf-8')
    print(f'%(glibc)  realpath("{name}")')

def after_readlink():
    rname = gdb.parse_and_eval('rname').string('utf-8')
    print(f'%(glibc)    readlink("{rname}") -> ', end='')
    n = int(gdb.parse_and_eval('n'))
    if n >= 0:
        buf = gdb.parse_and_eval('buf').string('utf-8')[:n]
        print(f'"{buf}"')
    else:
        errno = int(gdb.parse_and_eval('errno'))
        print(f'{errorcode[errno]} ({errno})')
end
############################################################

# Resume execution from JDK_Canonicalize()
continue
EOF

It produces the following output:

%(java) Content:
%(java) => Process-Substitution
%(java)
%(java) File::getCanonicalPath:
%(glibc)  realpath("/dev/fd/63")
%(glibc)    readlink("/dev") -> EINVAL (22)
%(glibc)    readlink("/dev/fd") -> "/proc/self/fd"
%(glibc)    readlink("/proc") -> EINVAL (22)
%(glibc)    readlink("/proc/self") -> "1390261"
%(glibc)    readlink("/proc/1390261") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd/63") -> "pipe:[18671624]"
%(glibc)    readlink("/proc/1390261/fd/pipe:[18671624]") -> ENOENT (2)
%(glibc)  realpath("/dev/fd")
%(glibc)    readlink("/dev") -> EINVAL (22)
%(glibc)    readlink("/dev/fd") -> "/proc/self/fd"
%(glibc)    readlink("/proc") -> EINVAL (22)
%(glibc)    readlink("/proc/self") -> "1390261"
%(glibc)    readlink("/proc/1390261") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd") -> EINVAL (22)
%(java) => /proc/1390261/fd/63
%(java)
%(java) Path::toRealPath:
%(glibc)  realpath("/dev/fd/63")
%(glibc)    readlink("/dev") -> EINVAL (22)
%(glibc)    readlink("/dev/fd") -> "/proc/self/fd"
%(glibc)    readlink("/proc") -> EINVAL (22)
%(glibc)    readlink("/proc/self") -> "1390261"
%(glibc)    readlink("/proc/1390261") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd") -> EINVAL (22)
%(glibc)    readlink("/proc/1390261/fd/63") -> "pipe:[18671624]"
%(glibc)    readlink("/proc/1390261/fd/pipe:[18671624]") -> ENOENT (2)
Exception in thread "main" java.nio.file.NoSuchFileException: /dev/fd/63
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:106)
	at java.base/sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:111)
	at java.base/sun.nio.fs.UnixPath.toRealPath(UnixPath.java:834)
	at GetContentAndRealPath.main(GetContentAndRealPath.java:24)

We can see that realpath() invokes readlink() several times for the partially normalized path. Anonymous pipes live in the PipeFS virtual filesystem, which isn't mounted in userspace. However, anonymous pipes do have a dentry which gives them a dname with the pipe:[<i_ino>] format (Code: readlink syscalldo_readlinkatvfs_readlinkproc_pid_readlinkdo_proc_readlinkd_pathpipefs_dname. Also, there are some higher level explanations here and here).

When readlink() resolves /proc/<pid>/fd/63, it returns the pipe:[18543635] link target, which makes the symlink look "broken" (from the userspace perspective) and this ultimately makes realpath() fail with ENOENT (2).

However, the unresolved /proc/<pid>/fd/0 link works, as the Linux Kernel can access the anonymous pipe behind it:

fferrari@vmhost:~$ echo TEST | sleep 20 &>/dev/null &disown
[1] 1386980
fferrari@vmhost:~$ cat $(realpath /proc/$(pgrep sleep)/fd/0)
cat: '/proc/1386980/fd/pipe:[18629664]': No such file or directory
fferrari@vmhost:~$ cat /proc/$(pgrep sleep)/fd/0
TEST

OpenJDK APIs difference

We can see in UnixNativeDispatcher::realpath0 that Path::toRealPath translates the glibc's realpath() failure immediately.

On the other hand File::getCanonicalPath goes through UnixFileSystem::canonicalize0 and JDK_Canonicalize, which retries realpath() until some subpath works. This aligns with the previous GDB experiment, which showed File::getCanonicalPath is doing an additional realpath("/dev/fd") call when realpath("/dev/fd/63") fails.

Given it just follows the glibc's behaviour, I don't think a Path::toRealPath change is justifiable for an nio-dev request. For example, an equivalent discussion has been raised for Rust, and the developers aren't considering making any change.

File::getCanonicalPath seems to take the best-effort approach (both in Linux and Windows), whereas Path::toRealPath is stricter.

@AlanBateman
Copy link
Contributor

AlanBateman commented Apr 15, 2025

File::getCanonicalPath seems to take the best-effort approach (both in Linux and Windows), whereas Path::toRealPath is stricter.

Path::toRealPath is doing the right thing, and consistent with realpath(2). The issue with File::getCanonicalXXX is that it is specified to return a canonical file even if it doesn't exist, so this is why you see a lot more code to compute a result.

Maybe the recursive include check them maybe it should use the file key instead.

Update copyright year, improve comments and use File::toPath to convert
back to Path.
@martinuy
Copy link
Contributor

Looks like File::getCanonicalPath is more resilient to canonicalization and resolution failures. This observation makes me wonder the following:

  1. Can a normalization failure (e.g. return of a partially normalized path) affect relative includes?

  2. Can a failure in symlinks resolution (which seems to be ignored) affect relative includes?

Startup will fail if the included file is not found, which is a safe behavior. The problematic scenario would be one in which a different file exists in a location that uses the partially normalized or resolved path as a base, and is not what the "include" properties writer intended. While unlikely, I want to understand if this is possible and a downside of using File::getCanonicalPath.

Junctions do not require elevation, so this is a way of testing
soft-links are resolved without requiring elevation. This is useful
because we need to avoid elevation in order to reproduce the parent
directories permission issue.

This is testing directories structure:

 📁 JDK-8352728-tmp-*/
 ├─🔒 jdk-parent-dir/         (ACL with REMOVED-PERMISSIONS)
 │ └─📁 jdk/
 │   ├─📁 conf/
 │   │ ├─📁 security/
 │   │ │ ├─📄 java.security
 │   │ │ │    📝 include link-to-other-dir/other.properties
 │   │ │ ├─🔗 link-to-other-dir/ ⟹ 📁 JDK-8352728-tmp-*/other-dir
 │   │ │ └─...               (JUNCTION)
 │   │ └─...
 │   └─...
 ├─📁 other-dir/
 │ └─📄 other.properties
 │      📝 include ../relatively.included.properties
 └─📄 relatively.included.properties
      📝 test.property.name=test_property_value
@franferrax
Copy link
Contributor Author

Hi @martinuy,

Looks like File::getCanonicalPath is more resilient to canonicalization and resolution failures. This observation makes me wonder the following:

  1. Can a normalization failure (e.g. return of a partially normalized path) affect relative includes?
  2. Can a failure in symlinks resolution (which seems to be ignored) affect relative includes?

Startup will fail if the included file is not found, which is a safe behavior. The problematic scenario would be one in which a different file exists in a location that uses the partially normalized or resolved path as a base, and is not what the "include" properties writer intended. While unlikely, I want to understand if this is possible and a downside of using File::getCanonicalPath.

By the time we are resolving a relative include, we already performed the following two operations on the file issuing the include statement, in this order:

  • The path has been normalized, or partially normalized by File::getCanonicalFile
  • The file has been opened, and we are parsing it (so it's known to be readable and exist)

I would like to put aside filesystem items creation/deletion race conditions, which of course can occur, but would always be problematic, regardless of the path canonicalization mechanism. Excluding such scenarios, we need to think of edge cases where there is a partial normalization or a symlinks resolution failure, while at the same time, the file exists and is readable.

#1. Partial normalization, without symlinks resolution failure

As I understand it, in Linux, normalization involves resolving relative paths against the current working directory, substituting . or .. path items, and resolving symlinks. In Windows, in addition to the Linux normalization, there is the resolution of absolute paths without drive/unit against the current unit, and the case normalization for case-insensitive filesystems (including the unit letter).

The only Linux partial normalization case I'm aware of, includes symlinks failures (File::getCanonicalFile performs . and .. substitutions for inaccessible and nonexistent paths, see examples of this below).

There could be partial normalization cases in Windows, when FindFirstFileW fails due to parent directories permissions, and the actual filesystem items case is normalized up to a certain point. However, in such cases the resulting path is equivalent, and should work as expected for relative includes resolution.

#2. Partial normalization, with symlinks resolution failure

I can't think of a Linux scenario where a link is readable but not resolvable. I've tried the following.

mkdir /tmp/scratch
sudo mkdir /tmp/scratch/protected
sudo mkdir /tmp/scratch/protected/inner
sudo tee /tmp/scratch/protected/inner/regular.properties <<<'name=value' >/dev/null
tee /tmp/scratch/target.properties <<<'name=value' >/dev/null
sudo ln -s /tmp/scratch/target.properties /tmp/scratch/protected/link.properties
sudo ln -s /tmp/scratch/target.properties /tmp/scratch/protected/inner/link.properties
sudo chown -R $(whoami):$(whoami) /tmp/scratch/protected/inner
sudo chmod 766 /tmp/scratch/protected

This is the created directories structure:

fferrari@vmhost:~$ sudo tree -up /tmp/scratch
[drwxr-xr-x fferrari]  /tmp/scratch
├── [drwxrw-rw- root    ]  protected
│   ├── [drwxr-xr-x fferrari]  inner
│   │   ├── [lrwxrwxrwx fferrari]  link.properties -> /tmp/scratch/target.properties
│   │   └── [-rw-r--r-- fferrari]  regular.properties
│   └── [lrwxrwxrwx root    ]  link.properties -> /tmp/scratch/target.properties
└── [-rw-r--r-- fferrari]  target.properties

3 directories, 4 files
fferrari@vmhost:~$ cat /tmp/scratch/target.properties
name=value

Sice protected doesn't have execution permissions, we can't even open regular files inside inner:

fferrari@vmhost:~$ cat /tmp/scratch/protected/inner/regular.properties
cat: /tmp/scratch/protected/inner/regular.properties: Permission denied
fferrari@vmhost:~$ cat /tmp/scratch/protected/inner/link.properties
cat: /tmp/scratch/protected/inner/link.properties: Permission denied

This means that if we are trying to load /tmp/scratch/protected/inner/link.properties, even if /tmp/scratch/target.properties had a relative include, we wouldn't face its resolution, because /tmp/scratch/protected/inner/link.properties isn't readable.

Even in those cases, File::getCanonicalFile gives a reasonable result, and is able to substitute . and ..:

fferrari@vmhost:~$ jshell -q -<<<'System.out.println(Path.of("/tmp/scratch/protected/inner/./regular.properties").toFile().getCanonicalFile())'
/tmp/scratch/protected/inner/regular.properties
fferrari@vmhost:~$ jshell -q -<<<'System.out.println(Path.of("/tmp/scratch/protected/inner/../inner/link.properties").toFile().getCanonicalFile())'
/tmp/scratch/protected/inner/link.properties

In Windows, as explained in the description, even if FindFirstFileW fails at some point, File::getCanonicalFile will proceed with a later symlinks resolution. This edge case is exercised by the new test added in 7abb62c. So I can't think of a scenario where a symlink/soft-link that is readable isn't resolved.

Final note

I might be missing something, so let me know if you have any other ideas to try. I provided commands to recreate my experiments in case you want to build on top of them.

@franferrax
Copy link
Contributor Author

@AlanBateman are you ok with letting the original c6f1d5f reviewers know of this fix and take a look? Or do you think further discussion is needed somewhere else?

@AlanBateman
Copy link
Contributor

@AlanBateman are you ok with letting the original c6f1d5f reviewers know of this fix and take a look? Or do you think further discussion is needed somewhere else?

Have you had time to try using the file key to detect the recursive include case?

@bridgekeeper
Copy link

bridgekeeper bot commented May 29, 2025

@franferrax This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply issue a /touch or /keepalive command to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@andrew-m-leonard
Copy link

@franferrax Is there any progress on this fix please?

@bridgekeeper
Copy link

bridgekeeper bot commented Jul 11, 2025

@franferrax This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply issue a /touch or /keepalive command to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@franferrax
Copy link
Contributor Author

Hi @AlanBateman, I'm resuming the work on this one.

Have you had time to try using the file key to detect the recursive include case?

By "file key", do you mean Files.readAttributes(Path.of("..."), BasicFileAttributes.class).fileKey()? This works on Linux but returns null on Windows.

If you mean using a java.util.Set<java.io.File> for circular includes detection, we can use it, but File::equals and File::hashCode operate on abstract pathnames without accessing the filesystem. This is not a problem as any circular include will eventually repeat verbatim anyway, but please note that we still need to perform symlinks resolution before resolving a relative include.

The original JDK-8352728 problem involves hitting the java.nio permissions problem when the master $JAVA_HOME/conf/security/java.security file is resolved. We can avoid this by doing the symlinks resolution only when strictly necessary, i.e. when a relative include is found and processed. In that case, we should also resolve symlinks in debugging messages, for a straightforward troubleshooting.

However, delayed symlinks resolution with java.nio doesn't resolve the permissions problem when a relative include is processed from a symlink/junction properties file. We discovered this additional issue as a variant of the reported one, and is exercised by the current version of ConfigFileTestDirPermissions (7abb62c).

Finally, if we are going to use java.io.File, we should also consider avoiding java.nio.file.Path, for a cleaner/unmixed usage of the APIs.

All this leaded me to explore and compare the following alternatives, in decreasing order of my personal preference:

Code Delayed symlinks resolution Main API (and circular detection) Symlinks resolution API Solves the original JDK-8352728 problem Solves the 7abb62c variant
Current PR, franferrax/jdk@e29e9fc No java.nio.file.Path java.io.File Yes Yes
franferrax/jdk@54db250 No java.io.File java.io.File Yes Yes
franferrax/jdk@a408dc5 Yes java.io.File java.io.File Yes Yes
franferrax/jdk@c9a3985 Yes java.nio.file.Path java.io.File Yes Yes
franferrax/jdk@fbec2fd Yes java.nio.file.Path java.nio.file.Path Yes No
Current master No java.nio.file.Path java.nio.file.Path No No

NOTEs:

  • I don't have a strong preference between the first two alternatives, but java.nio looks like a newer API, so perhaps preferred over java.io
  • I prefer the alternatives without delayed symlinks resolution because:
    • They have simpler and more uniform code: don't require an additional resolution for debugging messages
    • Circular include errors will show the resolved name (if we go with a delayed symlinks resolution alternative, we need to adjust either ConfigFileTest or that error message)

This use case has been discussed and analyzed in the pull request, but
we didn't have a test case for it. By introducing a test, we make sure
we don't have regressions in this area, regardless of the alternative
we choose to advance with for this fix.
@bridgekeeper
Copy link

bridgekeeper bot commented Aug 25, 2025

@franferrax This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply issue a /touch or /keepalive command to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@franferrax
Copy link
Contributor Author

Hi @AlanBateman, have you had time to review my previous message?

@bridgekeeper
Copy link

bridgekeeper bot commented Sep 29, 2025

@franferrax This pull request has been inactive for more than 4 weeks and will be automatically closed if another 4 weeks passes without any activity. To avoid this, simply issue a /touch or /keepalive command to the pull request. Feel free to ask for assistance if you need help with progressing this pull request towards integration!

@franferrax
Copy link
Contributor Author

/touch

@openjdk
Copy link

openjdk bot commented Sep 29, 2025

@franferrax The pull request is being re-evaluated and the inactivity timeout has been reset.

@DRSchlaubi
Copy link

DRSchlaubi commented Oct 11, 2025

I would like to point out, this also causes JDK 24+ to be incompatible with Microsofts UWP platform, as an UWP app also doesn't have permissions to the parent folder

@AlanBateman
Copy link
Contributor

Hi @AlanBateman, I'm resuming the work on this one.

Have you had time to try using the file key to detect the recursive include case?

By "file key", do you mean Files.readAttributes(Path.of("..."), BasicFileAttributes.class).fileKey()? This works on Linux but returns null on Windows.

Can you try Files.isSameFile? It will use the file key on all platforms. If the file located by the included path, and the previous files encountered, are accessible then it can be loadFromPath..

@seanjmullan
Copy link
Member

@franferrax Have you tried Alan's suggestion?

Also, since this is a regression, and you think it will take longer to come up with a fix, can I suggest as a temporary fix you revert to using FileInputStream to load the security properties file (in the default location or via the -Djava.security.properties system property), and only call loadFromPath if an include directive is specified? Is that feasible? If so, you can then address this issue separately. This should at least fix the common case where no include directive is specified. It would be good to get the primary use case fixed in 26.

@franferrax
Copy link
Contributor Author

@AlanBateman: I will try it, but please let me insist on the fact that the circular include detection is a secondary problem here (it's solved for any of the alternatives and the current mainline code). However, we still need a reliable way to determine the base directory for relative includes.

@seanjmullan: that would be more or less the franferrax/jdk@fbec2fd alternative from this comment (instead of using FileInputStream, just avoid java.nio.file.Path::toRealPath, not only if there isn't any include, but also for all the include statements that are absolute). Please also refer to the table in the comment, as that alternative doesn't solve the 7abb62c variant.

This PR has been opened for 205 days now, and I've put a considerable effort:

  • Debugging and documenting the underlying OS APIs (here and here)
  • Thinking and testing possible problematic scenarios (here)
  • Analyzing and developing a test for a variant of the original issue (7abb62c)
  • Providing and testing several alternatives (here)

I understand there is more rush now because we found more customers affected by it, but that doesn't change my position, I'm still convinced that the currently proposed fix is the best option among the analyzed alternatives (or perhaps franferrax/jdk@c9a3985, if we prefer to be conservative at the cost of a slightly less simple code).

I don't believe it should take longer to come up with a fix.

checkCyclicInclude() is invoked after we successfully get an InputStream
for the path to avoid skipping the same IOException several times inside
checkCyclicInclude() if the path doesn't exist.

Also, perform symlinks resolution only in the following cases:
 • When we need to resolve a relative include
 • For clarity to the user in logging messages
 • For clarity to the user in exception messages

In the first case, the resolution is a requirement, in the last two
cases it is a nice-to-have. But given the last two are exceptional
cases anyway, we let any resolution error bubble up.
@franferrax
Copy link
Contributor Author

@AlanBateman: Files::isSameFile works well for circular includes detection (in both Linux and Windows). I've updated the code to use it (see a8d865c).

The new code also avoids resolving a path, except in the following cases:

  • When we need to resolve a relative include
  • For clarity to the user, in logging messages
  • For clarity to the user, in exception messages

In those cases, I kept File::getCanonicalPath as the resolution method, because is the only one that is able to handle the 7abb62c variant.

@seanjmullan: the new code avoids path resolution whenever possible (except logging and exception messages), so it represents the most conservative approach (except for any possible regression of the new Files::isSameFile usage).

Please give it a look (or let Weijun/Valerie know).

if (isRegularFile) {
path = path.toRealPath();
} else if (Files.isDirectory(path)) {
if (!isRegularFile && Files.isDirectory(path)) {
Copy link
Member

Choose a reason for hiding this comment

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

Can a directory ever be a regular file? If not, you don't need the !isRegularFile check.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A directory is not a regular file, but we need isRegularFile later here:

currentPath = isRegularFile ? path : null;

So !isRegularFile is part of the condition to save the posible Files::isDirectory I/O operations in the most likely case (we are including a regular file).

Previous code was also saving this I/O:

if (isRegularFile) {
path = path.toRealPath();
} else if (Files.isDirectory(path)) {
throw new IOException("Is a directory");
} else {

I would prefer not to, but I can remove the condition and inline Files::isRegularFile on line 302:

currentPath = Files.isRegularFile(path) ? path : null;

throw new InternalError(
"Cyclic include of '" + resolve(path) + "'");
}
} catch (IOException ignore) {}
Copy link
Member

Choose a reason for hiding this comment

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

Not sure you want to ignore this - seems better to let this propagate and be thrown as an InternalError.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We can make this an InternalError, the most common failure case is one of the two files nonexistence. So before proceeding I want to make sure you are aware that this would make the following filesystem race-condition noticeable:

  1. File A is included, OpenJDK starts reading it
  2. File A is deleted by and administrator who is changing the settings
    • But OpenJDK keeps it open, this is possible in Linux
  3. File B is included, OpenJDK wants to check for a circular inclusion
  4. Files.isSameFile(path, activePath) throws IOException when path is file B and activePath is file A (now deleted)
  5. IOException isn't ignored but wrapped in an InternalError and thrown

Current code wouldn't fail in this scenario, although I recognize it's a corner case. I decided to ignore the exception under the assumption that Files.isSameFile(x, y) can be treated as false in this context for cases in which either x or y is nonexistent.

try {
if (Files.isSameFile(path, activePath)) {
throw new InternalError(
"Cyclic include of '" + resolve(path) + "'");
Copy link
Member

Choose a reason for hiding this comment

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

Why try to resolve the path for an exception message? If that causes an exception an InternalError will be thrown and this error message will be lost, making it harder to debug.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Was just for a nicer error message, but I agree it could make things even harder to debug than an unresolved path.

I will be changing this, adjusting the test case and re-testing.

Copy link
Contributor Author

@franferrax franferrax Nov 13, 2025

Choose a reason for hiding this comment

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

Done: 40bc832 & 4483469.

@franferrax
Copy link
Contributor Author

Hi @seanjmullan, thanks for your review, I replied to the three comments. I will start by removing resolve() when a cycle is detected, which will require test updates. For the remaining two, I'll wait for your responses before making any change.

// fault-tolerant, since the canonical form of a pathname is
// specified to exist even for nonexistent/inaccessible files.
try {
return path.toFile().getCanonicalFile().toPath();
Copy link
Contributor

Choose a reason for hiding this comment

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

@seanjmullan If this change is integrated, can we create a follow-up on issue to replace it? I strongly disagree with the changes in this PR, we should not be using File::getCanonicalFile here to work around an issue with an inaccessible parent directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just wanted to note that I also dislike this change, but is the only way I found to support relative includes in Windows UWP Java applications.

I'm open to other suggestions, including going back to Path::toRealPath and dropping that use-case (while still fixing the original JDK-8352728 issue), even though that isn't my preference.

@AlanBateman: do you disagree with other changes in this PR? I'm not comfortable integrating something that we haven't agreed upon.

OutputAnalyzer::stderrMatches returns a boolean while
OutputAnalyzer::stderrShouldMatch performs the check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rfr Pull request is ready for review security security-dev@openjdk.org

Development

Successfully merging this pull request may close these issues.

6 participants