Prevent I/O when checking UNC paths against --root-dir (#1466)

Test on [windows] please
This commit is contained in:
Jen Basch
2026-03-25 11:40:51 -07:00
committed by GitHub
parent 1104f12362
commit cdc6fa8aec
4 changed files with 71 additions and 5 deletions

View File

@@ -44,6 +44,24 @@ public interface SecurityManager {
*/ */
void checkResolveResource(URI resource) throws SecurityManagerException; void checkResolveResource(URI resource) throws SecurityManagerException;
/**
* Resolves the given {@code file:} URI to a secure, symlink-free path that has been verified to
* be within the root directory (if one is configured). The returned path can be opened with
* {@link java.nio.file.LinkOption#NOFOLLOW_LINKS}.
*
* <p>Returns {@code null} for non-{@code file:} URIs or if no root directory is configured.
*
* @param uri the URI to resolve
* @param isResource denotes if uri belongs to a resource (otherwise, a module)
* @return the resolved, symlink-free path under root directory, or {@code null}
* @throws SecurityManagerException if the resolved path is not within the root directory
* @throws IOException if the path cannot be resolved
*/
default @Nullable Path resolveSecurePath(URI uri, boolean isResource)
throws SecurityManagerException, IOException {
return null;
}
/** /**
* Resolves the given {@code file:} URI to a secure, symlink-free path that has been verified to * Resolves the given {@code file:} URI to a secure, symlink-free path that has been verified to
* be within the root directory (if one is configured). The returned path can be opened with * be within the root directory (if one is configured). The returned path can be opened with
@@ -57,6 +75,6 @@ public interface SecurityManager {
* @throws IOException if the path cannot be resolved * @throws IOException if the path cannot be resolved
*/ */
default @Nullable Path resolveSecurePath(URI uri) throws SecurityManagerException, IOException { default @Nullable Path resolveSecurePath(URI uri) throws SecurityManagerException, IOException {
return null; return resolveSecurePath(uri, false);
} }
} }

View File

@@ -173,14 +173,20 @@ public final class SecurityManagers {
} }
@Override @Override
public @Nullable Path resolveSecurePath(URI uri) throws SecurityManagerException, IOException { public @Nullable Path resolveSecurePath(URI uri, boolean isResource)
if (rootDir == null || !uri.isAbsolute() || !uri.getScheme().equals("file")) { throws SecurityManagerException, IOException {
if (rootDir == null
|| !uri.isAbsolute()
|| !uri.getScheme().equals("file")
|| (uri.getAuthority() != null && !uri.getAuthority().isEmpty())) {
return null; return null;
} }
var path = Path.of(uri); var path = Path.of(uri);
var realPath = path.toRealPath(); var realPath = path.toRealPath();
if (!realPath.startsWith(rootDir)) { if (!realPath.startsWith(rootDir)) {
throw new SecurityManagerException(ErrorMessages.create("modulePastRootDir", uri, rootDir)); var errorMessageKey = isResource ? "resourcePastRootDir" : "modulePastRootDir";
var message = ErrorMessages.create(errorMessageKey, uri, rootDir);
throw new SecurityManagerException(message);
} }
return realPath; return realPath;
} }
@@ -225,6 +231,16 @@ public final class SecurityManagers {
if (rootDir == null || !checkUri.getScheme().equals("file")) return; if (rootDir == null || !checkUri.getScheme().equals("file")) return;
var path = Path.of(checkUri); var path = Path.of(checkUri);
// uri represents a UNC path if authority is non-null
// so treat this like a potentially redirected HTTP read:
// check if both the given and real paths are under rootDir
if (checkUri.getAuthority() != null && !checkUri.getAuthority().isEmpty()) {
doCheckIsUnderRootDir(path.normalize(), uri, isResource);
}
// if given path is under rootDir, do I/O to determine if the real path is under the root dir
// this can result in a nasty timeout (~20s) in Files.exists if the server doesn't respond :(
if (Files.exists(path)) { if (Files.exists(path)) {
try { try {
path = path.toRealPath(); path = path.toRealPath();
@@ -237,6 +253,12 @@ public final class SecurityManagers {
path = path.normalize(); path = path.normalize();
} }
doCheckIsUnderRootDir(path, uri, isResource);
}
private void doCheckIsUnderRootDir(Path path, URI uri, boolean isResource)
throws SecurityManagerException {
assert rootDir != null;
if (!path.startsWith(rootDir)) { if (!path.startsWith(rootDir)) {
var errorMessageKey = isResource ? "resourcePastRootDir" : "modulePastRootDir"; var errorMessageKey = isResource ? "resourcePastRootDir" : "modulePastRootDir";
var message = ErrorMessages.create(errorMessageKey, uri, rootDir); var message = ErrorMessages.create(errorMessageKey, uri, rootDir);

View File

@@ -262,7 +262,7 @@ public final class ResourceReaders {
// Use resolveSecurePath to get a symlink-free path verified under rootDir. // Use resolveSecurePath to get a symlink-free path verified under rootDir.
var securityManager = VmContext.get(null).getSecurityManager(); var securityManager = VmContext.get(null).getSecurityManager();
try { try {
var securePath = securityManager.resolveSecurePath(uri); var securePath = securityManager.resolveSecurePath(uri, true);
if (securePath != null) { if (securePath != null) {
try (var in = Files.newInputStream(securePath, LinkOption.NOFOLLOW_LINKS)) { try (var in = Files.newInputStream(securePath, LinkOption.NOFOLLOW_LINKS)) {
var content = in.readAllBytes(); var content = in.readAllBytes();

View File

@@ -22,6 +22,7 @@ import java.nio.file.FileSystems
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.io.path.createParentDirectories import kotlin.io.path.createParentDirectories
import kotlin.io.path.writeText import kotlin.io.path.writeText
@@ -29,7 +30,10 @@ import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatCode import org.assertj.core.api.Assertions.assertThatCode
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Timeout
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.condition.EnabledOnOs
import org.junit.jupiter.api.condition.OS
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import org.pkl.commons.createTempFile import org.pkl.commons.createTempFile
import org.pkl.commons.test.PackageServer import org.pkl.commons.test.PackageServer
@@ -540,6 +544,28 @@ class EvaluatorTest {
} }
} }
@Test
@EnabledOnOs(OS.WINDOWS)
@Timeout(1, unit = TimeUnit.SECONDS)
fun `root dir check happens without any UNC or SMB access`() {
val evaluator =
with(EvaluatorBuilder.preconfigured()) {
rootDir = Path.of("/tmp/test")
build()
}
// this uses a TEST-NET-1 IP which has no server running in order to force a timeout-driven
// failure (takes ~20s)
// root dir check failure should prevent any I/O and fail fast instead of hitting the timeout
val exc =
assertThrows<PklException> {
evaluator.evaluate(text("result = import(\"file://192.0.2.1/share/nope.pkl\")"))
}
assertThat(exc)
.hasMessageContaining(
"Refusing to load module `file://192.0.2.1/share/nope.pkl` because it is not within the root directory (`--root-dir`)."
)
}
private fun checkModule(module: PModule) { private fun checkModule(module: PModule) {
assertThat(module.properties.size).isEqualTo(2) assertThat(module.properties.size).isEqualTo(2)
assertThat(module.getProperty("name")).isEqualTo("pigeon") assertThat(module.getProperty("name")).isEqualTo("pigeon")