Skip to content

Commit 60f628e

Browse files
Fix possible race condition during module and resource reading (#1426)
1 parent 817e433 commit 60f628e

File tree

5 files changed

+93
-13
lines changed

5 files changed

+93
-13
lines changed

pkl-core/src/main/java/org/pkl/core/SecurityManager.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
2+
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -15,7 +15,10 @@
1515
*/
1616
package org.pkl.core;
1717

18+
import java.io.IOException;
1819
import java.net.URI;
20+
import java.nio.file.Path;
21+
import org.pkl.core.util.Nullable;
1922

2023
/**
2124
* Enforces a security model during {@link Evaluator evaluation}.
@@ -40,4 +43,20 @@ public interface SecurityManager {
4043
* to access the given URI.
4144
*/
4245
void checkResolveResource(URI resource) throws SecurityManagerException;
46+
47+
/**
48+
* Resolves the given {@code file:} URI to a secure, symlink-free path that has been verified to
49+
* be within the root directory (if one is configured). The returned path can be opened with
50+
* {@link java.nio.file.LinkOption#NOFOLLOW_LINKS}.
51+
*
52+
* <p>Returns {@code null} for non-{@code file:} URIs or if no root directory is configured.
53+
*
54+
* @param uri the URI to resolve
55+
* @return the resolved, symlink-free path under root directory, or {@code null}
56+
* @throws SecurityManagerException if the resolved path is not within the root directory
57+
* @throws IOException if the path cannot be resolved
58+
*/
59+
default @Nullable Path resolveSecurePath(URI uri) throws SecurityManagerException, IOException {
60+
return null;
61+
}
4362
}

pkl-core/src/main/java/org/pkl/core/SecurityManagers.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
2+
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -171,6 +171,19 @@ public void checkImportModule(URI importingModule, URI importedModule)
171171
}
172172
}
173173

174+
@Override
175+
public @Nullable Path resolveSecurePath(URI uri) throws SecurityManagerException, IOException {
176+
if (rootDir == null || !uri.isAbsolute() || !uri.getScheme().equals("file")) {
177+
return null;
178+
}
179+
var path = Path.of(uri);
180+
var realPath = path.toRealPath();
181+
if (!realPath.startsWith(rootDir)) {
182+
throw new SecurityManagerException(ErrorMessages.create("modulePastRootDir", uri, rootDir));
183+
}
184+
return realPath;
185+
}
186+
174187
private @Nullable Path normalizePath(@Nullable Path path) {
175188
if (path == null) {
176189
return null;

pkl-core/src/main/java/org/pkl/core/module/ModuleKeys.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
2+
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -345,10 +345,18 @@ public ResolvedModuleKey resolve(SecurityManager securityManager)
345345
if (java.io.File.separatorChar == '\\' && uriPath != null && uriPath.contains("\\")) {
346346
throw new FileNotFoundException();
347347
}
348-
var realPath = IoUtils.pathOf(uri).toRealPath();
348+
// Use resolveSecurePath to atomically resolve symlinks and verify under rootDir.
349+
// The returned path is symlink-free, so it can be opened with NOFOLLOW_LINKS.
350+
var securePath = securityManager.resolveSecurePath(uri);
351+
Path realPath;
352+
if (securePath != null) {
353+
realPath = securePath;
354+
} else {
355+
realPath = IoUtils.pathOf(uri).toRealPath();
356+
}
349357
var resolvedUri = realPath.toUri();
350358
securityManager.checkResolveModule(resolvedUri);
351-
return ResolvedModuleKeys.file(this, resolvedUri, realPath);
359+
return ResolvedModuleKeys.file(this, resolvedUri, realPath, securePath != null);
352360
}
353361

354362
@Override
@@ -413,8 +421,14 @@ public ResolvedModuleKey resolve(SecurityManager securityManager)
413421
throws IOException, SecurityManagerException {
414422
securityManager.checkResolveModule(uri);
415423

416-
var path = resolver.resolve(uri).toRealPath();
417-
return ResolvedModuleKeys.file(this, path.toUri(), path);
424+
var securePath = securityManager.resolveSecurePath(uri);
425+
Path path;
426+
if (securePath != null) {
427+
path = securePath;
428+
} else {
429+
path = resolver.resolve(uri).toRealPath();
430+
}
431+
return ResolvedModuleKeys.file(this, path.toUri(), path, securePath != null);
418432
}
419433
}
420434

pkl-core/src/main/java/org/pkl/core/module/ResolvedModuleKeys.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
2+
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -15,26 +15,36 @@
1515
*/
1616
package org.pkl.core.module;
1717

18-
import java.io.File;
1918
import java.io.IOException;
2019
import java.net.URI;
2120
import java.net.URL;
2221
import java.nio.charset.StandardCharsets;
2322
import java.nio.file.AccessDeniedException;
2423
import java.nio.file.Files;
24+
import java.nio.file.LinkOption;
2525
import java.nio.file.Path;
2626
import org.pkl.core.util.IoUtils;
2727

2828
/** Utilities for obtaining and using resolved module keys. */
2929
public final class ResolvedModuleKeys {
3030
private ResolvedModuleKeys() {}
3131

32+
/**
33+
* Creates a resolved module key backed by the given file path. The resulting module will be
34+
* loaded from that file path and cached using the given URI as cache key.
35+
*
36+
* @param nofollow if true, the file will be opened with {@link LinkOption#NOFOLLOW_LINKS}.
37+
*/
38+
public static ResolvedModuleKey file(ModuleKey original, URI uri, Path path, boolean nofollow) {
39+
return new FileKey(original, uri, path, nofollow);
40+
}
41+
3242
/**
3343
* Creates a resolved module key backed by the given file path. The resulting module will be
3444
* loaded from that file path and cached using the given URI as cache key.
3545
*/
3646
public static ResolvedModuleKey file(ModuleKey original, URI uri, Path path) {
37-
return new File(original, uri, path);
47+
return new FileKey(original, uri, path, false);
3848
}
3949

4050
/**
@@ -62,15 +72,17 @@ public static ResolvedModuleKey delegated(ResolvedModuleKey delegate, ModuleKey
6272
return new Delegated(delegate, original);
6373
}
6474

65-
private static class File implements ResolvedModuleKey {
75+
private static class FileKey implements ResolvedModuleKey {
6676
final ModuleKey original;
6777
final URI uri;
6878
final Path path;
79+
final boolean nofollow;
6980

70-
File(ModuleKey original, URI uri, Path path) {
81+
FileKey(ModuleKey original, URI uri, Path path, boolean nofollow) {
7182
this.original = original;
7283
this.uri = uri;
7384
this.path = path;
85+
this.nofollow = nofollow;
7486
}
7587

7688
@Override
@@ -86,6 +98,11 @@ public URI getUri() {
8698
@Override
8799
public String loadSource() throws IOException {
88100
try {
101+
if (nofollow) {
102+
try (var in = Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS)) {
103+
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
104+
}
105+
}
89106
return Files.readString(path, StandardCharsets.UTF_8);
90107
} catch (AccessDeniedException e) {
91108
// Windows throws `AccessDeniedException` when reading directories.

pkl-core/src/main/java/org/pkl/core/resource/ResourceReaders.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
2+
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,6 +22,8 @@
2222
import java.net.http.HttpRequest;
2323
import java.net.http.HttpResponse.BodyHandlers;
2424
import java.nio.file.Files;
25+
import java.nio.file.LinkOption;
26+
import java.nio.file.NoSuchFileException;
2527
import java.util.ArrayList;
2628
import java.util.Collections;
2729
import java.util.List;
@@ -257,6 +259,21 @@ private static final class FileResource extends UrlResource {
257259
@Override
258260
public Optional<Object> read(URI uri) throws IOException, URISyntaxException {
259261
IoUtils.validateFileUri(uri);
262+
// Use resolveSecurePath to get a symlink-free path verified under rootDir.
263+
var securityManager = VmContext.get(null).getSecurityManager();
264+
try {
265+
var securePath = securityManager.resolveSecurePath(uri);
266+
if (securePath != null) {
267+
try (var in = Files.newInputStream(securePath, LinkOption.NOFOLLOW_LINKS)) {
268+
var content = in.readAllBytes();
269+
return Optional.of(new Resource(uri, content));
270+
} catch (NoSuchFileException e) {
271+
return Optional.empty();
272+
}
273+
}
274+
} catch (SecurityManagerException e) {
275+
throw new IOException(e);
276+
}
260277
return super.read(uri);
261278
}
262279

0 commit comments

Comments
 (0)