mirror of
https://github.com/jenkinsci/jenkins.git
synced 2026-10-26 19:15:09 +00:00
[SECURITY-3657]
This commit is contained in:
1 parent
659b4380bf
commit
6dc9993760
2 files changed
+506
-6
No files matched your search
@@ -213,6 +213,15 @@ import org.kohsuke.stapler.Stapler;
|
||||
*/
|
||||
public final class FilePath implements SerializableOnlyOverRemoting {
|
||||
|
||||
/**
|
||||
* Set to {@code true} to disable validation to ensure that we do not attempt to extract paths that may allow determining the path to the destination directory.
|
||||
*/
|
||||
private static /* non-final for script console */ boolean ALLOW_REENTRY_PATH_TRAVERSAL = SystemProperties.getBoolean(FilePath.class.getName() + ".ALLOW_REENTRY_PATH_TRAVERSAL");
|
||||
/**
|
||||
* Set to {@code true} to disable the fix for SECURITY-3657 that prevents path traversal from crafted tar files.
|
||||
*/
|
||||
private static /* non-final for script console */ boolean ALLOW_UNTAR_SYMLINK_RESOLUTION = SystemProperties.getBoolean(FilePath.class.getName() + ".ALLOW_UNTAR_SYMLINK_RESOLUTION");
|
||||
|
||||
public enum DisplayOption implements OpenOption, CopyOption {
|
||||
IGNORE_TMP_DIRS
|
||||
}
|
||||
@@ -3051,26 +3060,54 @@ public final class FilePath implements SerializableOnlyOverRemoting {
|
||||
/**
|
||||
* Reads from a tar stream and stores obtained files to the base dir.
|
||||
* Supports large files > 10 GB since 1.627.
|
||||
* This prohibits any path traversal out of the base dir, as well as writing through any existing symlinks.
|
||||
*/
|
||||
private static void readFromTar(String name, File baseDir, InputStream in, Charset filenamesEncoding) throws IOException {
|
||||
|
||||
final File absoluteBaseDir = baseDir.getAbsoluteFile();
|
||||
final Path normalizedAbsoluteBaseDir = absoluteBaseDir.toPath().normalize();
|
||||
try (TarInputStream t = new TarInputStream(in, filenamesEncoding.name())) {
|
||||
TarEntry te;
|
||||
while ((te = t.getNextEntry()) != null) {
|
||||
File f = new File(baseDir, te.getName());
|
||||
if (!f.toPath().normalize().startsWith(baseDir.toPath())) {
|
||||
throw new IOException(
|
||||
"Tar " + name + " contains illegal file name that breaks out of the target directory: " + te.getName());
|
||||
final String entryName = te.getName();
|
||||
if (!ALLOW_REENTRY_PATH_TRAVERSAL) {
|
||||
if (new File(entryName).toPath().normalize().startsWith(Path.of(".."))) {
|
||||
// catch relative path that would escape and then enter the destination dir again, like `../../../var/jenkins_home/...`
|
||||
throw new IOException("Tar " + name + " contains entry that escapes destination directory: " + entryName);
|
||||
}
|
||||
}
|
||||
|
||||
// We cannot replace 'f' with its canonical path here, otherwise, if it is a symlink, it becomes its link target and attempting to overwrite 'f' will have unintended behavior (JENKINS-67063)
|
||||
File f = new File(baseDir, entryName).getAbsoluteFile();
|
||||
File parent = f.getParentFile();
|
||||
if (!f.toPath().normalize().startsWith(normalizedAbsoluteBaseDir)) {
|
||||
// This covers both relative path traversal, and potential undefined File(String, String) constructor behavior when it takes a second argument that's absolute.
|
||||
throw new IOException("Tar " + name + " contains entry that escapes destination directory: " + entryName);
|
||||
}
|
||||
|
||||
if (!ALLOW_UNTAR_SYMLINK_RESOLUTION) {
|
||||
// getCanonicalFile doesn't follow symlinks on Windows, so do this the hard way: Check each ancestor up to the base dir for whether it's a symlink
|
||||
File current = parent;
|
||||
while (current != null && !current.equals(absoluteBaseDir)) {
|
||||
if (Util.isSymlink(current)) {
|
||||
throw new IOException("Tar " + name + " attempts to write to file with symlink in path: " + entryName);
|
||||
}
|
||||
current = current.getParentFile();
|
||||
}
|
||||
}
|
||||
|
||||
if (te.isDirectory()) {
|
||||
mkdirs(f);
|
||||
} else {
|
||||
File parent = f.getParentFile();
|
||||
if (parent != null) mkdirs(parent);
|
||||
|
||||
if (te.isSymbolicLink()) {
|
||||
new FilePath(f).symlinkTo(te.getLinkName(), TaskListener.NULL);
|
||||
} else {
|
||||
if (!ALLOW_UNTAR_SYMLINK_RESOLUTION) {
|
||||
if (Util.isSymlink(f)) {
|
||||
throw new IOException("Tar '" + name + "' entry '" + entryName + "' would write through existing symlink: " + f);
|
||||
}
|
||||
}
|
||||
IOUtils.copy(t, f);
|
||||
|
||||
Files.setLastModifiedTime(Util.fileToPath(f), FileTime.from(te.getModTime().toInstant()));
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
package jenkins.security;
|
||||
|
||||
import static jenkins.security.Security3657Test.Entry.fileOrDir;
|
||||
import static jenkins.security.Security3657Test.Entry.symlink;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import hudson.FilePath;
|
||||
import hudson.Util;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.apache.tools.tar.TarConstants;
|
||||
import org.apache.tools.tar.TarEntry;
|
||||
import org.apache.tools.tar.TarOutputStream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.jvnet.hudson.test.Issue;
|
||||
|
||||
public class Security3657Test {
|
||||
|
||||
@Test
|
||||
void tarSymlinkPathTraversal(@TempDir File root) throws Exception {
|
||||
final FilePath tarfile = new FilePath(createTarFile(root, symlink("attacker", ".."), fileOrDir("attacker/pwned.txt")));
|
||||
|
||||
FilePath extractDir = new FilePath(new File(root, "extract"));
|
||||
extractDir.mkdirs();
|
||||
|
||||
IOException exception = assertThrows(IOException.class, () -> tarfile.untar(extractDir, FilePath.TarCompression.NONE));
|
||||
|
||||
// Symlink was created but file outside extraction dir was not
|
||||
assertTrue(extractDir.child("attacker").exists());
|
||||
assertFalse(new File(root, "pwned.txt").exists());
|
||||
|
||||
// Verify the error message mentions symlink in path
|
||||
String message = exception.getMessage();
|
||||
if (exception.getCause() != null) {
|
||||
message = exception.getCause().getMessage();
|
||||
}
|
||||
assertThat(message, containsString("symlink in path"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tarSymlinkPathTraversalEscapeHatch(@TempDir File root) throws Exception {
|
||||
final Field escapeHatch = FilePath.class.getDeclaredField("ALLOW_UNTAR_SYMLINK_RESOLUTION");
|
||||
escapeHatch.setAccessible(true);
|
||||
escapeHatch.setBoolean(null, true);
|
||||
try {
|
||||
final FilePath tarfile = new FilePath(createTarFile(root, symlink("attacker", ".."), fileOrDir("attacker/pwned.txt")));
|
||||
|
||||
FilePath extractDir = new FilePath(new File(root, "extract"));
|
||||
extractDir.mkdirs();
|
||||
|
||||
tarfile.untar(extractDir, FilePath.TarCompression.NONE);
|
||||
|
||||
// Symlink and file outside extraction dir were created
|
||||
assertTrue(extractDir.child("attacker").exists());
|
||||
assertTrue(new File(root, "pwned.txt").exists());
|
||||
} finally {
|
||||
escapeHatch.setBoolean(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recursiveLinks(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "other-link"), symlink("other-link", "link-file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("other-link")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selfLink(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "link-file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selfLink2(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "link-file"), symlink("link-file", "link-file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowNonExistentSymlinkTargets(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "src/main/whatever/non-existent-file"), fileOrDir("real-file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("real-file")));
|
||||
}
|
||||
|
||||
@Issue("JENKINS-67063")
|
||||
@Test
|
||||
void repeatedExtraction(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "src/main/whatever/some-file"), fileOrDir("src/main/whatever/some-file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("src/main/whatever/some-file")));
|
||||
}
|
||||
|
||||
@Issue("JENKINS-67063")
|
||||
@Test
|
||||
void differentExtraction(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "src/main/whatever/some-file"), fileOrDir("src/main/whatever/some-file"), fileOrDir("regular-file")));
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("src/main/whatever/some-file")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("regular-file")));
|
||||
|
||||
final FilePath otherTarFile = new FilePath(createTarFile(root, "other.tar", symlink("link-file", "src/main/whatever/some-file")));
|
||||
otherTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("src/main/whatever/some-file")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("regular-file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void directWriteThroughRelative(@TempDir File root) throws Exception {
|
||||
// We can only have a link name up to 100 bytes, which won't be enough for local/CI build workspaces, so improvise: relative PT, direct, to existing parent
|
||||
assertTrue(new File(root, "plugins").mkdirs());
|
||||
final FilePath pathTraversalTarFile = new FilePath(createTarFile(root, symlink("link-file", "../plugins/evil.hpi"), fileOrDir("link-file")));
|
||||
final FilePath extractDir = new FilePath(root).child("extract-base");
|
||||
extractDir.mkdirs();
|
||||
final IOException expected = assertThrows(IOException.class, () -> pathTraversalTarFile.untar(extractDir, FilePath.TarCompression.NONE));
|
||||
assertThat(expected.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertThat(expected.getCause().getMessage(), containsString("Tar 'crafted.tar' entry 'link-file' would write through existing symlink:"));
|
||||
|
||||
assertFalse(new File(root, "plugins/evil.hpi").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void directWriteThroughAbsolute(@TempDir File root) throws Exception {
|
||||
// We can only have a link name up to 100 bytes, which may not be enough for local/CI build workspaces, so improvise with system temp dir.
|
||||
// macOS 26.3 has temp dirs that look like /var/folders/sx/123456789012345678901234567890/T/ (49 chars), which is enough for this test.
|
||||
// It seems running this test in IntelliJ IDEA fails since that uses a different temp dir, but it works with command line `mvn`.
|
||||
final Path dir = Files.createTempDirectory("jenkins-test");
|
||||
try {
|
||||
final Path linkTargetPath = dir.resolve("evil.hpi");
|
||||
final FilePath pathTraversalTarFile = new FilePath(createTarFile(root, symlink("link-file", linkTargetPath.toString()), fileOrDir("link-file")));
|
||||
final FilePath extractDir = new FilePath(root).child("extract-base");
|
||||
extractDir.mkdirs();
|
||||
final IOException expected = assertThrows(IOException.class, () -> pathTraversalTarFile.untar(extractDir, FilePath.TarCompression.NONE));
|
||||
assertThat(expected.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertThat(expected.getCause().getMessage(), containsString("Tar 'crafted.tar' entry 'link-file' would write through existing symlink:"));
|
||||
|
||||
assertFalse(Files.exists(linkTargetPath));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(dir);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void directoryCreationPathTraversal(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", ".."), fileOrDir("link-file/bar/")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
|
||||
assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertThat(ioException.getCause().getMessage(), containsString("Tar crafted.tar attempts to write to file with symlink in path: link-file/bar/"));
|
||||
assertFalse(Files.isDirectory(root.toPath().resolve("bar")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void relativePathLegal(@TempDir Path root) throws Exception {
|
||||
// relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
|
||||
final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
|
||||
|
||||
final File extractDir = new File(relativeRoot, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, fileOrDir("dir/file")));
|
||||
final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Files.isDirectory(extractDir.toPath().resolve("dir")));
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("dir/file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void relativeBaseAllowedSymlinks(@TempDir Path root) throws Exception {
|
||||
// relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
|
||||
final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
|
||||
|
||||
final File extractDir = new File(relativeRoot, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, symlink("path/to/link-file", "../../file.txt"), symlink("path/to/other-file", "../../path/to/link-file")));
|
||||
final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/link-file")));
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/other-file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void relativeBaseDirectWriteThroughRelative(@TempDir Path root) throws Exception {
|
||||
// relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
|
||||
final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
|
||||
|
||||
// We can only have a link name up to 100 bytes, which won't be enough for local/CI build workspaces, so improvise: relative PT, direct, to existing parent
|
||||
assertTrue(new File(relativeRoot, "plugins").mkdirs());
|
||||
final FilePath pathTraversalTarFile = new FilePath(createTarFile(relativeRoot, symlink("link-file", "../plugins/evil.hpi"), fileOrDir("link-file")));
|
||||
final FilePath extractDir = new FilePath(relativeRoot).child("extract-base");
|
||||
extractDir.mkdirs();
|
||||
final IOException expected = assertThrows(IOException.class, () -> pathTraversalTarFile.untar(extractDir, FilePath.TarCompression.NONE));
|
||||
assertThat(expected.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertThat(expected.getCause().getMessage(), containsString("Tar 'crafted.tar' entry 'link-file' would write through existing symlink:"));
|
||||
|
||||
assertFalse(new File(relativeRoot, "plugins/evil.hpi").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
void relativeBaseBasicRelativePathTraversal(@TempDir Path root) throws Exception {
|
||||
// relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
|
||||
final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
|
||||
|
||||
final File extractDir = new File(relativeRoot, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, fileOrDir("../file.txt")));
|
||||
final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
|
||||
assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertFalse(Files.exists(extractDir.toPath().resolve("file.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void relativePathTraversalThroughSymlink(@TempDir Path root) throws Exception {
|
||||
// relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
|
||||
final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
|
||||
|
||||
final File extractDir = new File(relativeRoot, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, symlink("link-file", ".."), fileOrDir("link-file/bar/")));
|
||||
final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
|
||||
assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertThat(ioException.getCause().getMessage(), containsString("Tar crafted.tar attempts to write to file with symlink in path: link-file/bar/"));
|
||||
assertFalse(Files.isDirectory(relativeRoot.toPath().resolve("bar")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void directoryCreationDirect(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "../bar"), fileOrDir("link-file/")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
|
||||
assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertFalse(Files.exists(root.toPath().resolve("bar")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicAbsolutePathTraversal(@TempDir File root) throws Exception {
|
||||
// This test is weird since we cannot really assert anything based on java.io.File Javadoc and actual observed behavior. So just assert that the bad file doesn't get created.
|
||||
// We can only have a link name up to 100 bytes, which may not be enough for local/CI build workspaces, so improvise with system temp dir.
|
||||
// macOS 26.3 has temp dirs that look like /var/folders/sx/123456789012345678901234567890/T/ (49 chars), which is enough for this test.
|
||||
// It seems running this test in IntelliJ IDEA fails since that uses a different temp dir, but it works with command line `mvn`.
|
||||
final Path dir = Files.createTempDirectory("jenkins-test");
|
||||
try {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir(dir.resolve("file.txt").toString())));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
try {
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
} catch (Exception ignore) {
|
||||
}
|
||||
assertFalse(Files.exists(dir.resolve("file.txt")));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(dir);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void basicRelativePathTraversal(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("../file.txt")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
|
||||
assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertFalse(Files.exists(extractDir.toPath().resolve("file.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedSymlinks(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("path/to/link-file", "../../file.txt"), symlink("path/to/other-file", "../../path/to/link-file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/link-file")));
|
||||
assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/other-file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedPathTraversal(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("path/"), fileOrDir("path/../file")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Files.isRegularFile(extractDir.toPath().resolve("file")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeThenReEnterPathTraversal(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("../extract-base/foo")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
|
||||
assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
|
||||
assertThat(ioException.getCause().getMessage(), containsString("Tar crafted.tar contains entry that escapes destination directory: ../extract-base/foo"));
|
||||
|
||||
assertFalse(Files.exists(extractDir.toPath().resolve("foo")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeThenReEnterPathTraversalAllowed(@TempDir File root) throws Exception {
|
||||
final File extractDir = new File(root, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("../extract-base/foo")));
|
||||
final FilePath extractFilePath = new FilePath(root).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final Field escapeHatch = FilePath.class.getDeclaredField("ALLOW_REENTRY_PATH_TRAVERSAL");
|
||||
escapeHatch.setAccessible(true);
|
||||
escapeHatch.setBoolean(null, true);
|
||||
try {
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Files.exists(extractDir.toPath().resolve("foo")));
|
||||
} finally {
|
||||
escapeHatch.setBoolean(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeThenReEnterPathTraversalAllowedRelativeBase(@TempDir Path root) throws Exception {
|
||||
// relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
|
||||
final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
|
||||
|
||||
final File extractDir = new File(relativeRoot, "extract-base");
|
||||
assertTrue(extractDir.mkdirs());
|
||||
final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, fileOrDir("../extract-base/foo")));
|
||||
final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
|
||||
extractFilePath.mkdirs();
|
||||
final Field escapeHatch = FilePath.class.getDeclaredField("ALLOW_REENTRY_PATH_TRAVERSAL");
|
||||
escapeHatch.setAccessible(true);
|
||||
escapeHatch.setBoolean(null, true);
|
||||
try {
|
||||
symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
|
||||
assertTrue(Files.exists(extractDir.toPath().resolve("foo")));
|
||||
} finally {
|
||||
escapeHatch.setBoolean(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static File createTarFile(File base, Entry... entries) throws IOException {
|
||||
return createTarFile(base, "crafted.tar", entries);
|
||||
}
|
||||
|
||||
private static File createTarFile(File base, String fileName, Entry... entries) throws IOException {
|
||||
File tarFile = new File(base, fileName);
|
||||
|
||||
try (TarOutputStream tar = new TarOutputStream(Files.newOutputStream(tarFile.toPath()))) {
|
||||
for (Entry entry : entries) {
|
||||
entry.add(tar);
|
||||
}
|
||||
}
|
||||
return tarFile;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
void add(TarOutputStream tar) throws IOException;
|
||||
|
||||
/**
|
||||
* @param name the name of the file or folder. For folder, add trailing /
|
||||
*/
|
||||
static Entry fileOrDir(String name) {
|
||||
return new FileOrDirEntry(name);
|
||||
}
|
||||
|
||||
static Entry symlink(String name, String target) {
|
||||
return new SymlinkEntry(name, target);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
record FileOrDirEntry(String name) implements Entry {
|
||||
@Override
|
||||
public void add(TarOutputStream tar) throws IOException {
|
||||
TarEntry fileEntry = new TarEntry(name, true);
|
||||
byte[] content = "You've been pwned!".getBytes(StandardCharsets.UTF_8);
|
||||
if (!fileEntry.isDirectory()) {
|
||||
fileEntry.setSize(content.length);
|
||||
}
|
||||
tar.putNextEntry(fileEntry);
|
||||
if (!fileEntry.isDirectory()) {
|
||||
tar.write(content);
|
||||
}
|
||||
tar.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
record SymlinkEntry(String name, String target) implements Entry {
|
||||
@Override
|
||||
public void add(TarOutputStream tar) throws IOException {
|
||||
TarEntry symlinkEntry = new TarEntry(name, true);
|
||||
symlinkEntry.setLinkFlag(TarConstants.LF_SYMLINK);
|
||||
symlinkEntry.setLinkName(target);
|
||||
tar.putNextEntry(symlinkEntry);
|
||||
tar.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user