From c19cc705688cfffa4fe735e0edbe84862b6c135f Mon Sep 17 00:00:00 2001 From: Wadeck Follonier Date: Wed, 21 Nov 2018 13:41:15 +0100 Subject: [PATCH] [SECURITY-904] --- core/src/main/java/hudson/FilePath.java | 64 +- .../hudson/model/DirectoryBrowserSupport.java | 109 +++- core/src/main/java/hudson/model/Run.java | 2 +- .../main/java/jenkins/util/VirtualFile.java | 131 +++- .../test/java/hudson/FilePathSEC904Test.java | 312 +++++++++ core/src/test/java/hudson/UtilSEC904Test.java | 84 +++ .../jenkins/util/VirtualFileSEC904Test.java | 180 ++++++ .../DirectoryBrowserSupportSEC904Test.java | 606 ++++++++++++++++++ .../hudson/tasks/ArtifactArchiverTest.java | 2 +- .../insideWorkspaceStructure.bat | 7 + .../insideWorkspaceStructure.sh | 8 + .../outsideWorkspaceStructure.bat | 13 + .../outsideWorkspaceStructure.sh | 14 + ...outsideWorkspaceStructureWithJunctions.bat | 16 + 14 files changed, 1509 insertions(+), 39 deletions(-) create mode 100644 core/src/test/java/hudson/FilePathSEC904Test.java create mode 100644 core/src/test/java/hudson/UtilSEC904Test.java create mode 100644 core/src/test/java/jenkins/util/VirtualFileSEC904Test.java create mode 100644 test/src/test/java/hudson/model/DirectoryBrowserSupportSEC904Test.java create mode 100644 test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.bat create mode 100644 test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.sh create mode 100644 test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.bat create mode 100644 test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.sh create mode 100644 test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructureWithJunctions.bat diff --git a/core/src/main/java/hudson/FilePath.java b/core/src/main/java/hudson/FilePath.java index 16ebf7547a..f305d05836 100644 --- a/core/src/main/java/hudson/FilePath.java +++ b/core/src/main/java/hudson/FilePath.java @@ -80,8 +80,10 @@ import java.net.URLConnection; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.InvalidPathException; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.LinkOption; +import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.FileAttribute; import java.nio.file.attribute.PosixFilePermission; @@ -3044,33 +3046,61 @@ public final class FilePath implements Serializable { if (new File(potentialChildRelativePath).isAbsolute()) { throw new IllegalArgumentException("Only a relative path is supported, the given path is absolute: " + potentialChildRelativePath); } + + Path parentAbsolutePath = Util.fileToPath(parentFile.getAbsoluteFile()); + Path parentRealPath; + try { + parentRealPath = parentAbsolutePath.toRealPath(); + } + catch(NoSuchFileException e) { + throw new IllegalArgumentException("The parent does not exist"); + } - Path parent = parentFile.getAbsoluteFile().toPath().normalize(); - + // example: "a/b/c" that will become "b/c" then just "c", and finally an empty string String remainingPath = potentialChildRelativePath; - File currentFile = parentFile; + + Path currentFilePath = parentFile.toPath(); while (!remainingPath.isEmpty()) { - File directChild = this.getDirectChild(currentFile, remainingPath); - File childUsingFullPath = new File(currentFile, remainingPath); - remainingPath = childUsingFullPath.getAbsolutePath().substring(directChild.getAbsolutePath().length()); - - File childFileSymbolic = Util.resolveSymlinkToFile(directChild); + Path directChild = this.getDirectChild(currentFilePath, remainingPath); + Path childUsingFullPath = currentFilePath.resolve(remainingPath); + Path rel = directChild.toAbsolutePath().relativize(childUsingFullPath.toAbsolutePath()); + remainingPath = rel.toString(); + + File childFileSymbolic = Util.resolveSymlinkToFile(directChild.toFile()); if (childFileSymbolic == null) { - currentFile = directChild; + currentFilePath = directChild; } else { - currentFile = childFileSymbolic; + currentFilePath = childFileSymbolic.toPath(); + } + + Path currentFileAbsolutePath = currentFilePath.toAbsolutePath(); + try{ + Path child = currentFileAbsolutePath.toRealPath(); + if (!child.startsWith(parentRealPath)) { + return false; + } + } catch (NoSuchFileException e) { + // nonexistent file + // in case this folder / file will be copied somewhere else, + // it becomes the responsibility of that system to check the isDescendant with the existing links + // we are not taking the parentRealPath to avoid possible problem + try { + Path child = currentFileAbsolutePath.normalize(); + Path parent = parentAbsolutePath.normalize(); + return child.startsWith(parent); + } catch (InvalidPathException e2) { + throw new IOException(e2); + } } } - //TODO could be refactored using Util#isDescendant(File, File) from 2.80+ - Path child = currentFile.getAbsoluteFile().toPath().normalize(); - return child.startsWith(parent); + return true; } - private @CheckForNull File getDirectChild(File parentFile, String childPath){ - File current = new File(parentFile, childPath); - while (current != null && !parentFile.equals(current.getParentFile())) { - current = current.getParentFile(); + private @CheckForNull Path getDirectChild(Path parentPath, String childPath){ + Path current = parentPath.resolve(childPath); + while (current != null && !parentPath.equals(current.getParent())) { + current = current.getParent(); } return current; } diff --git a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java index 5426c7842e..3caf2266ef 100644 --- a/core/src/main/java/hudson/model/DirectoryBrowserSupport.java +++ b/core/src/main/java/hudson/model/DirectoryBrowserSupport.java @@ -23,6 +23,7 @@ */ package hudson.model; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.FilePath; import hudson.Util; import java.io.IOException; @@ -36,9 +37,12 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.StringTokenizer; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.servlet.ServletException; import javax.servlet.http.HttpServletResponse; import jenkins.model.Jenkins; @@ -64,6 +68,9 @@ import org.kohsuke.stapler.StaplerResponse; * @author Kohsuke Kawaguchi */ public final class DirectoryBrowserSupport implements HttpResponse { + // escape hatch for SECURITY-904 to keep legacy behavior + @SuppressFBWarnings(value = "MS_SHOULD_BE_FINAL", justification = "Accessible via System Groovy Scripts") + public static boolean ALLOW_SYMLINK_ESCAPE = Boolean.getBoolean(DirectoryBrowserSupport.class.getName() + ".allowSymlinkEscape"); public final ModelObject owner; @@ -212,13 +219,19 @@ public final class DirectoryBrowserSupport implements HttpResponse { String base = _base.toString(); String rest = _rest.toString(); + if(!ALLOW_SYMLINK_ESCAPE && (root.supportIsDescendant() && !root.isDescendant(base))){ + LOGGER.log(Level.WARNING, "Trying to access a file outside of the directory, target: "+ base); + rsp.sendError(HttpServletResponse.SC_FORBIDDEN, "Trying to access a file outside of the directory, target: " + base); + return; + } + // this is the base file/directory VirtualFile baseFile = root.child(base); if(baseFile.isDirectory()) { if(zip) { rsp.setContentType("application/zip"); - zip(rsp.getOutputStream(), baseFile, rest); + zip(rsp, root, baseFile, rest); return; } if (plain) { @@ -246,8 +259,8 @@ public final class DirectoryBrowserSupport implements HttpResponse { } List> glob = null; - - if(rest.length()>0) { + boolean patternUsed = rest.length() > 0; + if(patternUsed) { // the rest is Ant glob pattern glob = patternScan(baseFile, rest, createBackRef(restSize)); } else @@ -257,13 +270,15 @@ public final class DirectoryBrowserSupport implements HttpResponse { } if(glob!=null) { + List> filteredGlob = keepReadabilityOnlyOnDescendants(baseFile, patternUsed, glob); + // serve glob req.setAttribute("it", this); List parentPaths = buildParentPath(base,restSize); req.setAttribute("parentPath",parentPaths); req.setAttribute("backPath", createBackRef(restSize)); req.setAttribute("topPath", createBackRef(parentPaths.size()+restSize)); - req.setAttribute("files", glob); + req.setAttribute("files", filteredGlob); req.setAttribute("icon", icon); req.setAttribute("path", path); req.setAttribute("pattern",rest); @@ -319,6 +334,57 @@ public final class DirectoryBrowserSupport implements HttpResponse { rsp.serveFile(req, in, lastModified, -1, length, baseFile.getName() ); } } + + private List> keepReadabilityOnlyOnDescendants(VirtualFile root, boolean patternUsed, List> pathFragmentsList){ + Stream> pathFragmentsStream = pathFragmentsList.stream().map((List pathFragments) -> { + List mappedFragments = new ArrayList<>(pathFragments.size()); + String relativePath = ""; + for (int i = 0; i < pathFragments.size(); i++) { + Path current = pathFragments.get(i); + if (i == 0) { + relativePath = current.title; + } else { + relativePath += "/" + current.title; + } + + if (!current.isReadable) { + if (patternUsed) { + // we do not want to leak information about existence of folders / files satisfying the pattern inside that folder + return null; + } + mappedFragments.add(current); + return mappedFragments; + } else { + if (isDescendant(root, relativePath)) { + mappedFragments.add(current); + } else { + if (patternUsed) { + // we do not want to leak information about existence of folders / files satisfying the pattern inside that folder + return null; + } + mappedFragments.add(Path.createNotReadableVersionOf(current)); + return mappedFragments; + } + } + } + return mappedFragments; + }); + + if (patternUsed) { + pathFragmentsStream = pathFragmentsStream.filter(Objects::nonNull); + } + + return pathFragmentsStream.collect(Collectors.toList()); + } + + private boolean isDescendant(VirtualFile root, String relativePath){ + try { + return ALLOW_SYMLINK_ESCAPE || !root.supportIsDescendant() || root.isDescendant(relativePath); + } + catch (IOException e) { + return false; + } + } private String getPath(StaplerRequest req) { String path = req.getRestOfPath(); @@ -352,7 +418,8 @@ public final class DirectoryBrowserSupport implements HttpResponse { return buf.toString(); } - private static void zip(OutputStream outputStream, VirtualFile dir, String glob) throws IOException { + private static void zip(StaplerResponse rsp, VirtualFile root, VirtualFile dir, String glob) throws IOException, InterruptedException { + OutputStream outputStream = rsp.getOutputStream(); try (ZipOutputStream zos = new ZipOutputStream(outputStream)) { zos.setEncoding(System.getProperty("file.encoding")); // TODO JENKINS-20663 make this overridable via query parameter for (String n : dir.list(glob.length() == 0 ? "**" : glob)) { @@ -363,18 +430,24 @@ public final class DirectoryBrowserSupport implements HttpResponse { } else { relativePath = n; } - // In ZIP archives "All slashes MUST be forward slashes" (http://pkware.com/documents/casestudies/APPNOTE.TXT) - // TODO On Linux file names can contain backslashes which should not treated as file separators. - // Unfortunately, only the file separator char of the master is known (File.separatorChar) - // but not the file separator char of the (maybe remote) "dir". - ZipEntry e = new ZipEntry(relativePath.replace('\\', '/')); - VirtualFile f = dir.child(n); - e.setTime(f.lastModified()); - zos.putNextEntry(e); - try (InputStream in = f.open()) { - IOUtils.copy(in, zos); + + String targetFile = dir.toString().substring(root.toString().length()) + n; + if (!ALLOW_SYMLINK_ESCAPE && root.supportIsDescendant() && !root.isDescendant(targetFile)) { + LOGGER.log(Level.INFO, "Trying to access a file outside of the directory: " + root + ", illicit target: " + targetFile); + } else { + // In ZIP archives "All slashes MUST be forward slashes" (http://pkware.com/documents/casestudies/APPNOTE.TXT) + // TODO On Linux file names can contain backslashes which should not treated as file separators. + // Unfortunately, only the file separator char of the master is known (File.separatorChar) + // but not the file separator char of the (maybe remote) "dir". + ZipEntry e = new ZipEntry(relativePath.replace('\\', '/')); + VirtualFile f = dir.child(n); + e.setTime(f.lastModified()); + zos.putNextEntry(e); + try (InputStream in = f.open()) { + IOUtils.copy(in, zos); + } + zos.closeEntry(); } - zos.closeEntry(); } } } @@ -446,6 +519,10 @@ public final class DirectoryBrowserSupport implements HttpResponse { return size; } + public static Path createNotReadableVersionOf(Path that){ + return new Path(that.href, that.title, that.isFolder, that.size, false); + } + private static final long serialVersionUID = 1L; } diff --git a/core/src/main/java/hudson/model/Run.java b/core/src/main/java/hudson/model/Run.java index a23ccb7ff5..417e7a5b9c 100644 --- a/core/src/main/java/hudson/model/Run.java +++ b/core/src/main/java/hudson/model/Run.java @@ -1151,7 +1151,7 @@ public abstract class Run ,RunT extends Run, Serializab return callable.call(); } + /** + * Determine if the implementation supports the {@link #isDescendant(String)} method + * + * TODO un-restrict it in a weekly after the patch + */ + @Restricted(NoExternalUse.class) + public boolean supportIsDescendant() { + return false; + } + + /** + * Check if the relative path is really a descendant of this folder, following the symbolic links. + * Meant to be used in coordination with {@link #child(String)}. + * + * TODO un-restrict it in a weekly after the patch + */ + @Restricted(NoExternalUse.class) + public boolean isDescendant(String childRelativePath) throws IOException { + return false; + } + /** * Creates a virtual file wrapper for a local file. * @param f a disk file (need not exist) @@ -301,6 +326,7 @@ public abstract class VirtualFile implements Comparable, Serializab throw new IOException(e); } } + private boolean isIllegalSymlink() { // TODO JENKINS-26838 try { String myPath = f.toPath().toRealPath(new LinkOption[0]).toString(); @@ -316,6 +342,54 @@ public abstract class VirtualFile implements Comparable, Serializab } return false; } + + /** + * TODO un-restrict it in a weekly after the patch + */ + @Override + @Restricted(NoExternalUse.class) + public boolean supportIsDescendant() { + return true; + } + + /** + * TODO un-restrict it in a weekly after the patch + */ + @Override + @Restricted(NoExternalUse.class) + public boolean isDescendant(String potentialChildRelativePath) throws IOException { + if (new File(potentialChildRelativePath).isAbsolute()) { + throw new IllegalArgumentException("Only a relative path is supported, the given path is absolute: " + potentialChildRelativePath); + } + + FilePath root = new FilePath(this.root); + String relativePath = computeRelativePathToRoot(); + + try { + return root.isDescendant(relativePath + potentialChildRelativePath); + } + catch (InterruptedException e) { + return false; + } + } + + /** + * To be kept in sync with {@link FilePathVF#computeRelativePathToRoot()} + */ + private String computeRelativePathToRoot(){ + if (this.root.equals(this.f)) { + return ""; + } + + Deque relativePath = new LinkedList<>(); + File current = this.f; + while (current != null && !current.equals(this.root)) { + relativePath.addFirst(current.getName()); + current = current.getParentFile(); + } + + return String.join(File.separator, relativePath) + File.separator; + } } /** @@ -324,12 +398,14 @@ public abstract class VirtualFile implements Comparable, Serializab * @return a wrapper */ public static VirtualFile forFilePath(final FilePath f) { - return new FilePathVF(f); + return new FilePathVF(f, f); } private static final class FilePathVF extends VirtualFile { private final FilePath f; - FilePathVF(FilePath f) { + private final FilePath root; + FilePathVF(FilePath f, FilePath root) { this.f = f; + this.root = root; } @Override public String getName() { return f.getName(); @@ -367,7 +443,7 @@ public abstract class VirtualFile implements Comparable, Serializab List kids = f.list(); VirtualFile[] vfs = new VirtualFile[kids.size()]; for (int i = 0; i < vfs.length; i++) { - vfs[i] = forFilePath(kids.get(i)); + vfs[i] = new FilePathVF(kids.get(i), this.root); } return vfs; } catch (InterruptedException x) { @@ -382,7 +458,7 @@ public abstract class VirtualFile implements Comparable, Serializab } } @Override public VirtualFile child(String name) { - return forFilePath(f.child(name)); + return new FilePathVF(f.child(name), this.root); } @Override public long length() throws IOException { try { @@ -419,6 +495,53 @@ public abstract class VirtualFile implements Comparable, Serializab throw (IOException) new IOException(x.toString()).initCause(x); } } + + /** + * TODO un-restrict it in a weekly after the patch + */ + @Override + @Restricted(NoExternalUse.class) + public boolean supportIsDescendant() { + return true; + } + + /** + * TODO un-restrict it in a weekly after the patch + */ + @Override + @Restricted(NoExternalUse.class) + public boolean isDescendant(String potentialChildRelativePath) throws IOException { + if (new File(potentialChildRelativePath).isAbsolute()) { + throw new IllegalArgumentException("Only a relative path is supported, the given path is absolute: " + potentialChildRelativePath); + } + + String relativePath = computeRelativePathToRoot(); + + try { + return this.root.isDescendant(relativePath + potentialChildRelativePath); + } + catch (InterruptedException e) { + return false; + } + } + + /** + * To be kept in sync with {@link FileVF#computeRelativePathToRoot()} + */ + private String computeRelativePathToRoot(){ + if (this.root.equals(this.f)) { + return ""; + } + + LinkedList relativePath = new LinkedList<>(); + FilePath current = this.f; + while (current != null && !current.equals(this.root)) { + relativePath.addFirst(current.getName()); + current = current.getParent(); + } + + return String.join(File.separator, relativePath) + File.separator; + } } private static final class Scanner extends MasterToSlaveFileCallable { private final String glob; diff --git a/core/src/test/java/hudson/FilePathSEC904Test.java b/core/src/test/java/hudson/FilePathSEC904Test.java new file mode 100644 index 0000000000..f72050de61 --- /dev/null +++ b/core/src/test/java/hudson/FilePathSEC904Test.java @@ -0,0 +1,312 @@ +/* + * The MIT License + * + * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi, Alan Harder + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.Issue; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +//TODO to be merged back in FilePathTest after security release +public class FilePathSEC904Test { + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + @Issue("SECURITY-904") + public void isDescendant_regularFiles() throws IOException, InterruptedException { + // root + // /workspace + // /sub + // sub-regular.txt + // regular.txt + // /protected + // secret.txt + FilePath rootFolder = new FilePath(temp.newFolder("root")); + FilePath workspaceFolder = rootFolder.child("workspace"); + FilePath subFolder = workspaceFolder.child("sub"); + FilePath protectedFolder = rootFolder.child("protected"); + + FilePath regularFile = workspaceFolder.child("regular.txt"); + regularFile.write("regular-file", StandardCharsets.UTF_8.name()); + FilePath subRegularFile = subFolder.child("sub-regular.txt"); + subRegularFile.write("sub-regular-file", StandardCharsets.UTF_8.name()); + + FilePath secretFile = protectedFolder.child("secret.txt"); + secretFile.write("secrets", StandardCharsets.UTF_8.name()); + + assertTrue(workspaceFolder.isDescendant(".")); + assertTrue(workspaceFolder.isDescendant("regular.txt")); + assertTrue(workspaceFolder.isDescendant("./regular.txt")); + assertTrue(workspaceFolder.isDescendant("sub/sub-regular.txt")); + assertTrue(workspaceFolder.isDescendant("sub//sub-regular.txt")); + assertTrue(workspaceFolder.isDescendant("sub/../sub/sub-regular.txt")); + assertTrue(workspaceFolder.isDescendant("./sub/../sub/sub-regular.txt")); + + // nonexistent files + assertTrue(workspaceFolder.isDescendant("nonexistent.txt")); + assertTrue(workspaceFolder.isDescendant("sub/nonexistent.txt")); + assertTrue(workspaceFolder.isDescendant("nonexistent/nonexistent.txt")); + assertFalse(workspaceFolder.isDescendant("../protected/nonexistent.txt")); + assertFalse(workspaceFolder.isDescendant("../nonexistent/nonexistent.txt")); + + // the intermediate path "./.." goes out of workspace and so is refused + assertFalse(workspaceFolder.isDescendant("./../workspace")); + assertFalse(workspaceFolder.isDescendant("./../workspace/")); + assertFalse(workspaceFolder.isDescendant("./../workspace/regular.txt")); + assertFalse(workspaceFolder.isDescendant("../workspace/regular.txt")); + assertFalse(workspaceFolder.isDescendant("./../../root/workspace/regular.txt")); + + // attempt to reach other folder + assertFalse(workspaceFolder.isDescendant("../protected/secret.txt")); + assertFalse(workspaceFolder.isDescendant("./../protected/secret.txt")); + } + + @Test + @Issue("SECURITY-904") + public void isDescendant_regularSymlinks() throws IOException, InterruptedException { + // root + // /workspace + // /a + // a.txt + // /b + // _a => symlink to ../a + // _atxt => symlink to ../a/a.txt + // regular.txt + // _nonexistent => symlink to nonexistent (nonexistent folder) + // _nonexistentUp => symlink to ../nonexistent (nonexistent folder + illegal) + // _protected => symlink to ../protected (illegal) + // _secrettxt => symlink to ../protected/secret.txt (illegal) + // /protected + // secret.txt + FilePath rootFolder = new FilePath(temp.newFolder("root")); + FilePath workspaceFolder = rootFolder.child("workspace"); + FilePath aFolder = workspaceFolder.child("a"); + FilePath bFolder = workspaceFolder.child("b"); + FilePath protectedFolder = rootFolder.child("protected"); + + FilePath regularFile = workspaceFolder.child("regular.txt"); + regularFile.write("regular-file", StandardCharsets.UTF_8.name()); + FilePath aFile = aFolder.child("a.txt"); + aFile.write("a-file", StandardCharsets.UTF_8.name()); + FilePath bFile = bFolder.child("a.txt"); + bFile.write("b-file", StandardCharsets.UTF_8.name()); + bFolder.child("_a").symlinkTo("../a", null); + bFolder.child("_atxt").symlinkTo("../a/a.txt", null); + // illegal symlinks + workspaceFolder.child("_protected").symlinkTo("../protected", null); + workspaceFolder.child("_nonexistent").symlinkTo("nonexistent", null); + workspaceFolder.child("_nonexistentUp").symlinkTo("../nonexistent", null); + workspaceFolder.child("_secrettxt").symlinkTo("../protected/secret.txt", null); + + FilePath secretFile = protectedFolder.child("secret.txt"); + secretFile.write("secrets", StandardCharsets.UTF_8.name()); + + assertTrue(workspaceFolder.isDescendant("regular.txt")); + assertTrue(workspaceFolder.isDescendant("_nonexistent")); + assertTrue(workspaceFolder.isDescendant("a")); + assertTrue(workspaceFolder.isDescendant("a/a.txt")); + assertTrue(workspaceFolder.isDescendant("b")); + assertTrue(workspaceFolder.isDescendant("b/_a/a.txt")); + assertTrue(workspaceFolder.isDescendant("b/_atxt")); + + // nonexistent but illegal + assertFalse(workspaceFolder.isDescendant("_nonexistentUp")); + // illegal symlinks + assertFalse(workspaceFolder.isDescendant("_protected")); + assertFalse(workspaceFolder.isDescendant("_protected/")); + assertFalse(workspaceFolder.isDescendant("_protected/secret.txt")); + assertFalse(workspaceFolder.isDescendant("./_protected/secret.txt")); + assertFalse(workspaceFolder.isDescendant("_secrettxt")); + assertFalse(workspaceFolder.isDescendant("./_secrettxt")); + } + + @Test + @Issue("SECURITY-904") + public void isDescendant_windowsSpecificSymlinks() throws Exception { + assumeTrue(Functions.isWindows()); + // root + // /workspace + // /a + // a.txt + // /b + // b.txt + // _a => junction to ../a + // regular.txt + // _nonexistent => junction to nonexistent (nonexistent folder) + // _nonexistentUp => junction to ../nonexistent (nonexistent and illegal) + // _protected => junction to ../protected (illegal) + // /protected + // secret.txt + File root = temp.newFolder("root"); + FilePath rootFolder = new FilePath(root); + FilePath workspaceFolder = rootFolder.child("workspace"); + FilePath aFolder = workspaceFolder.child("a"); + FilePath bFolder = workspaceFolder.child("b"); + FilePath protectedFolder = rootFolder.child("protected"); + + FilePath regularFile = workspaceFolder.child("regular.txt"); + regularFile.write("regular-file", StandardCharsets.UTF_8.name()); + FilePath aFile = aFolder.child("a.txt"); + aFile.write("a-file", StandardCharsets.UTF_8.name()); + FilePath bFile = bFolder.child("a.txt"); + bFile.write("b-file", StandardCharsets.UTF_8.name()); + + createJunction(new File(root, "/workspace/b/_a"), new File(root, "/workspace/a")); + createJunction(new File(root, "/workspace/_nonexistent"), new File(root, "/workspace/nonexistent")); + createJunction(new File(root, "/workspace/_nonexistentUp"), new File(root, "/nonexistent")); + createJunction(new File(root, "/workspace/_protected"), new File(root, "/protected")); + + FilePath secretFile = protectedFolder.child("secret.txt"); + secretFile.write("secrets", StandardCharsets.UTF_8.name()); + + assertTrue(workspaceFolder.isDescendant("b")); + assertTrue(workspaceFolder.isDescendant("b/_a/a.txt")); + + // nonexistent and not proven illegal, the junction links are not resolved + // by Util.resolveSymlinkToFile / neither Path.toRealPath under Windows + assertTrue(workspaceFolder.isDescendant("_nonexistent")); + assertTrue(workspaceFolder.isDescendant("_nonexistent/")); + assertTrue(workspaceFolder.isDescendant("_nonexistent/..")); + assertTrue(workspaceFolder.isDescendant("_nonexistentUp")); + + // illegal symlinks + assertFalse(workspaceFolder.isDescendant("_protected")); + assertFalse(workspaceFolder.isDescendant("_protected/../a")); + } + + private void createJunction(File from, File to) throws Exception { + Process p = Runtime.getRuntime().exec(new String[]{"cmd", "/c", "mklink", "/J", from.getAbsolutePath(), to.getAbsolutePath()}); + p.waitFor(2, TimeUnit.SECONDS); + } + + @Test(expected = IllegalArgumentException.class) + @Issue("SECURITY-904") + public void isDescendant_throwIfParentDoesNotExist_symlink() throws Exception { + FilePath rootFolder = new FilePath(temp.newFolder("root")); + FilePath aFolder = rootFolder.child("a"); + aFolder.mkdirs(); + FilePath linkToNonexistent = aFolder.child("linkToNonexistent"); + linkToNonexistent.symlinkTo("__nonexistent__", null); + + linkToNonexistent.isDescendant("."); + } + + @Test(expected = IllegalArgumentException.class) + @Issue("SECURITY-904") + public void isDescendant_throwIfParentDoesNotExist_directNonexistent() throws Exception { + FilePath rootFolder = new FilePath(temp.newFolder("root")); + FilePath nonexistent = rootFolder.child("nonexistent"); + nonexistent.isDescendant("."); + } + + @Test(expected = IllegalArgumentException.class) + @Issue("SECURITY-904") + public void isDescendant_throwIfAbsolutePathGiven() throws Exception { + FilePath rootFolder = new FilePath(temp.newFolder("root")); + rootFolder.mkdirs(); + rootFolder.isDescendant(temp.newFile().getAbsolutePath()); + } + + @Test + @Issue("SECURITY-904") + public void isDescendant_worksEvenInSymbolicWorkspace() throws Exception { + // root + // /w + // /_workspace => symlink to ../workspace + // /workspace + // /a + // a.txt + // /b + // _a => symlink to ../a + // _atxt => symlink to ../a/a.txt + // regular.txt + // _nonexistent => symlink to nonexistent (nonexistent folder) + // _nonexistentUp => symlink to ../nonexistent (nonexistent folder + illegal) + // _protected => symlink to ../protected (illegal) + // _secrettxt => symlink to ../protected/secret.txt (illegal) + // /protected + // secret.txt + FilePath rootFolder = new FilePath(temp.newFolder("root")); + FilePath wFolder = rootFolder.child("w"); + FilePath workspaceFolder = rootFolder.child("workspace"); + FilePath aFolder = workspaceFolder.child("a"); + FilePath bFolder = workspaceFolder.child("b"); + FilePath protectedFolder = rootFolder.child("protected"); + + FilePath regularFile = workspaceFolder.child("regular.txt"); + regularFile.write("regular-file", StandardCharsets.UTF_8.name()); + FilePath aFile = aFolder.child("a.txt"); + aFile.write("a-file", StandardCharsets.UTF_8.name()); + FilePath bFile = bFolder.child("a.txt"); + bFile.write("b-file", StandardCharsets.UTF_8.name()); + bFolder.child("_a").symlinkTo("../a", null); + bFolder.child("_atxt").symlinkTo("../a/a.txt", null); + // illegal symlinks + workspaceFolder.child("_protected").symlinkTo("../protected", null); + workspaceFolder.child("_protected2").symlinkTo("../../protected", null); + workspaceFolder.child("_nonexistent").symlinkTo("nonexistent", null); + workspaceFolder.child("_nonexistentUp").symlinkTo("../nonexistent", null); + workspaceFolder.child("_secrettxt").symlinkTo("../protected/secret.txt", null); + workspaceFolder.child("_secrettxt2").symlinkTo("../../protected/secret.txt", null); + + wFolder.mkdirs(); + FilePath symbolicWorkspace = wFolder.child("_w"); + symbolicWorkspace.symlinkTo("../workspace", null); + + FilePath secretFile = protectedFolder.child("secret.txt"); + secretFile.write("secrets", StandardCharsets.UTF_8.name()); + + assertTrue(symbolicWorkspace.isDescendant("regular.txt")); + assertTrue(symbolicWorkspace.isDescendant("_nonexistent")); + assertTrue(symbolicWorkspace.isDescendant("a")); + assertTrue(symbolicWorkspace.isDescendant("a/a.txt")); + assertTrue(symbolicWorkspace.isDescendant("b")); + assertTrue(symbolicWorkspace.isDescendant("b/_a/a.txt")); + assertTrue(symbolicWorkspace.isDescendant("b/_atxt")); + + // nonexistent but illegal + assertFalse(symbolicWorkspace.isDescendant("_nonexistentUp")); + // illegal symlinks + assertFalse(symbolicWorkspace.isDescendant("_protected")); + assertFalse(symbolicWorkspace.isDescendant("_protected/")); + assertFalse(symbolicWorkspace.isDescendant("_protected/secret.txt")); + assertFalse(symbolicWorkspace.isDescendant("./_protected/secret.txt")); + assertFalse(symbolicWorkspace.isDescendant("_protected2")); + assertFalse(symbolicWorkspace.isDescendant("_protected2/secret.txt")); + assertFalse(symbolicWorkspace.isDescendant("_secrettxt")); + assertFalse(symbolicWorkspace.isDescendant("./_secrettxt")); + assertFalse(symbolicWorkspace.isDescendant("_secrettxt2")); + } +} diff --git a/core/src/test/java/hudson/UtilSEC904Test.java b/core/src/test/java/hudson/UtilSEC904Test.java new file mode 100644 index 0000000000..7bb7896395 --- /dev/null +++ b/core/src/test/java/hudson/UtilSEC904Test.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi, + * Daniel Dyer, Erik Ramfelt, Richard Bair, id:cactusman + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson; + +import hudson.model.TaskListener; +import org.apache.commons.io.FileUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +/** + * @author Kohsuke Kawaguchi + */ +//TODO merge into UtilTest after the security release +public class UtilSEC904Test { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void resolveSymlinkToFile() throws Exception { + // root + // /a + // /aa + // aa.txt + // /_b => symlink to /root/b + // /b + // /_a => symlink to /root/a + File root = tmp.getRoot(); + File a = new File(root, "a"); + File aa = new File(a, "aa"); + aa.mkdirs(); + File aaTxt = new File(aa, "aa.txt"); + FileUtils.write(aaTxt, "aa"); + + File b = new File(root, "b"); + b.mkdir(); + + File _a = new File(b, "_a"); + Util.createSymlink(_a.getParentFile(), a.getAbsolutePath(), _a.getName(), TaskListener.NULL); + + File _b = new File(a, "_b"); + Util.createSymlink(_b.getParentFile(), b.getAbsolutePath(), _b.getName(), TaskListener.NULL); + + assertTrue(Files.isSymbolicLink(_a.toPath())); + assertTrue(Files.isSymbolicLink(_b.toPath())); + + // direct symlinks are resolved + assertEquals(Util.resolveSymlinkToFile(_a), a); + assertEquals(Util.resolveSymlinkToFile(_b), b); + + // intermediate symlinks are NOT resolved + assertNull(Util.resolveSymlinkToFile(new File(_a, "aa"))); + assertNull(Util.resolveSymlinkToFile(new File(_a, "aa/aa.txt"))); + } +} diff --git a/core/src/test/java/jenkins/util/VirtualFileSEC904Test.java b/core/src/test/java/jenkins/util/VirtualFileSEC904Test.java new file mode 100644 index 0000000000..8b25c1c040 --- /dev/null +++ b/core/src/test/java/jenkins/util/VirtualFileSEC904Test.java @@ -0,0 +1,180 @@ +/* + * The MIT License + * + * Copyright 2015 Jesse Glick. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.util; + +import hudson.FilePath; +import hudson.model.TaskListener; +import org.apache.commons.io.FileUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.Issue; + +import java.io.File; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +//TODO merge into VirtualFileTest after the security release +public class VirtualFileSEC904Test { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + // root + // /a + // /aa + // /aaa + // /_b2 => symlink to /root/b + // aa.txt + // /ab + // ab.txt + // /_b => symlink to /root/b + // /b + // /_a => symlink to /root/a + // /ba + // ba.txt + private void prepareFileStructureForIsDescendant() throws Exception { + File root = tmp.getRoot(); + File a = new File(root, "a"); + File aa = new File(a, "aa"); + File aaa = new File(aa, "aaa"); + aaa.mkdirs(); + File aaTxt = new File(aa, "aa.txt"); + FileUtils.write(aaTxt, "aa"); + + File ab = new File(a, "ab"); + ab.mkdirs(); + File abTxt = new File(ab, "ab.txt"); + FileUtils.write(abTxt, "ab"); + + File b = new File(root, "b"); + + File ba = new File(b, "ba"); + ba.mkdirs(); + File baTxt = new File(ba, "ba.txt"); + FileUtils.write(baTxt, "ba"); + + File _a = new File(b, "_a"); + new FilePath(_a).symlinkTo(a.getAbsolutePath(), TaskListener.NULL); + + File _b = new File(a, "_b"); + new FilePath(_b).symlinkTo(b.getAbsolutePath(), TaskListener.NULL); + File _b2 = new File(aaa, "_b2"); + new FilePath(_b2).symlinkTo(b.getAbsolutePath(), TaskListener.NULL); + } + + @Issue("SECURITY-904") + @Test public void forFile_isDescendant() throws Exception { + this.prepareFileStructureForIsDescendant(); + + File root = tmp.getRoot(); + File a = new File(root, "a"); + File aa = new File(a, "aa"); + VirtualFile virtualRoot = VirtualFile.forFile(root); + // keep the root information for isDescendant + VirtualFile virtualRootChildA = virtualRoot.child("a"); + VirtualFile virtualFromA = VirtualFile.forFile(a); + + checkCommonAssertionForIsDescendant(virtualRoot, virtualRootChildA, virtualFromA, aa.getAbsolutePath()); + } + + @Test + @Issue("SECURITY-904") + public void forFilePath_isDescendant() throws Exception { + this.prepareFileStructureForIsDescendant(); + + File root = tmp.getRoot(); + File a = new File(root, "a"); + File aa = new File(a, "aa"); + VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root)); + // keep the root information for isDescendant + VirtualFile virtualRootChildA = virtualRoot.child("a"); + VirtualFile virtualFromA = VirtualFile.forFilePath(new FilePath(a)); + + checkCommonAssertionForIsDescendant(virtualRoot, virtualRootChildA, virtualFromA, aa.getAbsolutePath()); + } + + private void checkCommonAssertionForIsDescendant(VirtualFile virtualRoot, VirtualFile virtualRootChildA, VirtualFile virtualFromA, String absolutePath) throws Exception { + try { + virtualRootChildA.isDescendant(absolutePath); + fail("isDescendant should have refused the absolute path"); + } catch (IllegalArgumentException e) {} + + assertTrue(virtualRootChildA.isDescendant("aa")); + assertTrue(virtualRootChildA.isDescendant("aa/aa.txt")); + assertTrue(virtualRootChildA.isDescendant("aa\\aa.txt")); + assertTrue(virtualRootChildA.isDescendant("ab")); + assertTrue(virtualRootChildA.isDescendant("ab/ab.txt")); + assertTrue(virtualRootChildA.isDescendant("ab//ab.txt")); + assertTrue(virtualRootChildA.isDescendant("ab/nonExistingFile.txt")); + assertTrue(virtualRootChildA.isDescendant("nonExistingFolder")); + assertTrue(virtualRootChildA.isDescendant("nonExistingFolder/nonExistingFile.txt")); + + assertTrue(virtualRootChildA.isDescendant("_b")); + assertTrue(virtualRootChildA.isDescendant("_b/ba")); + assertTrue(virtualRootChildA.isDescendant("_b/ba/ba.txt")); + assertTrue(virtualRootChildA.isDescendant("aa/aaa/_b2")); + assertTrue(virtualRootChildA.isDescendant("aa/aaa/_b2/ba")); + assertTrue(virtualRootChildA.isDescendant("aa/aaa/_b2/ba/ba.txt")); + + // such approach could be used to check existence of file inside symlink + assertTrue(virtualRootChildA.isDescendant("_b/ba/ba-unexistingFile.txt")); + + // we go outside, then inside = forbidden, could be used to check existence of symlinks + assertTrue(virtualRootChildA.isDescendant("_b/_a")); + assertTrue(virtualRootChildA.isDescendant("_b/_a/aa")); + assertTrue(virtualRootChildA.isDescendant("_b/_a/aa/aa.txt")); + + assertTrue(virtualFromA.isDescendant("aa")); + assertFalse(virtualFromA.isDescendant("_b")); + assertFalse(virtualFromA.isDescendant("_b/ba/ba-unexistingFile.txt")); + assertFalse(virtualFromA.isDescendant("_b/_a")); + assertFalse(virtualFromA.isDescendant("_b/_a/aa")); + assertFalse(virtualFromA.isDescendant("_b/_a/aa/aa.txt")); + assertFalse(virtualFromA.isDescendant("aa/aaa/_b2")); + assertFalse(virtualFromA.isDescendant("aa/aaa/_b2/ba")); + assertFalse(virtualFromA.isDescendant("aa/aaa/_b2/ba/ba.txt")); + + assertTrue(virtualRoot.isDescendant("aa")); + assertTrue(virtualRoot.isDescendant("aa/aa.txt")); + assertTrue(virtualRoot.isDescendant("ab")); + assertTrue(virtualRoot.isDescendant("ab/ab.txt")); + assertTrue(virtualRoot.isDescendant("ab/nonExistingFile.txt")); + assertTrue(virtualRoot.isDescendant("nonExistingFolder")); + assertTrue(virtualRoot.isDescendant("nonExistingFolder/nonExistingFile.txt")); + + assertTrue(virtualRoot.isDescendant("_b")); + assertTrue(virtualRoot.isDescendant("_b/ba")); + assertTrue(virtualRoot.isDescendant("_b/ba/ba.txt")); + assertTrue(virtualRoot.isDescendant("_b/_a")); + assertTrue(virtualRoot.isDescendant("_b/_a/aa")); + assertTrue(virtualRoot.isDescendant("_b/_a/aa/")); + assertTrue(virtualRoot.isDescendant("_b/_a/aa/../ab/ab.txt")); + assertTrue(virtualRoot.isDescendant("_b/_a/aa/aa.txt")); + } +} diff --git a/test/src/test/java/hudson/model/DirectoryBrowserSupportSEC904Test.java b/test/src/test/java/hudson/model/DirectoryBrowserSupportSEC904Test.java new file mode 100644 index 0000000000..06c9b057a4 --- /dev/null +++ b/test/src/test/java/hudson/model/DirectoryBrowserSupportSEC904Test.java @@ -0,0 +1,606 @@ +/* + * The MIT License + * + * Copyright (c) 2004-2009, Sun Microsystems, Inc., Kohsuke Kawaguchi + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package hudson.model; + +import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.UnexpectedPage; +import hudson.FilePath; +import hudson.Functions; +import hudson.tasks.BatchFile; +import hudson.tasks.Shell; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.junit.Assume; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.util.List; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +public class DirectoryBrowserSupportSEC904Test { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @Test + @Issue("SECURITY-904") + public void symlink_outsideWorkspace_areNotAllowed() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + + File secretsFolder = new File(j.jenkins.getRootDir(), "secrets"); + File secretTarget = new File(secretsFolder, "goal.txt"); + String secretContent = "secret"; + FileUtils.write(secretTarget, secretContent); + + /* + * secrets/ + * goal.txt + * workspace/ + * intermediateFolder/ + * public2.key + * otherFolder/ + * to_secret3 -> ../../../../secrets/ + * to_secret2 -> ../../../secrets/ + * to_secret_goal2 -> ../../../secrets/goal.txt + * public1.key + * to_secret1 -> ../../secrets/ + * to_secret_goal1 -> ../../secrets/goal.txt + * + */ + if (Functions.isWindows()) { + // no need to test mklink /H since we cannot create an hard link to a non-existing file + // and so you need to have access to the master file system directly which is already a problem + + String script = loadContentFromResource("outsideWorkspaceStructure.bat"); + p.getBuildersList().add(new BatchFile(script)); + } else { + String script = loadContentFromResource("outsideWorkspaceStructure.sh"); + p.getBuildersList().add(new Shell(script)); + } + + assertEquals(Result.SUCCESS, p.scheduleBuild2(0).get().getResult()); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + { // workspace root must be reachable (regular case) + Page page = wc.goTo(p.getUrl() + "ws/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + containsString("public1.key"), + containsString("intermediateFolder"), + containsString("to_secrets1"), + containsString("to_secrets_goal1"), + not(containsString("to_secrets2")), + not(containsString("to_secrets_goal2")) + )); + } + { // to_secrets1 not reachable + Page page = wc.goTo(p.getUrl() + "ws/to_secrets1/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // to_secrets_goal1 not reachable + Page page = wc.goTo(p.getUrl() + "ws/to_secrets_goal1/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // intermediateFolder must be reachable (regular case) + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + not(containsString("to_secrets1")), + not(containsString("to_secrets_goal1")), + containsString("to_secrets2"), + containsString("to_secrets_goal2") + )); + } + { // to_secrets2 not reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets2/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // using symbolic in the intermediate path + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets2/master.key", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // to_secrets_goal2 not reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets_goal2/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + + // pattern search feature + { // the pattern allow us to search inside the files / folders, + // without the patch the master.key from inside the outside symlinks would have been linked + Page page = wc.goTo(p.getUrl() + "ws/**/*.key", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + not(containsString("master.key")), + containsString("public1.key"), + containsString("public2.key") + )); + } + + // zip feature + { // all the outside folders / files are not included in the zip + Page zipPage = wc.goTo(p.getUrl() + "ws/*zip*/ws.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, containsInAnyOrder( + p.getName() + "/intermediateFolder/public2.key", + p.getName() + "/public1.key" + )); + } + { // all the outside folders / files are not included in the zip + Page zipPage = wc.goTo(p.getUrl() + "ws/intermediateFolder/*zip*/intermediateFolder.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, contains("intermediateFolder/public2.key")); + } + } + + /* + * If the glob filter is used, we do not want that it leaks some information. + * Presence of a folder means that the folder contains one or multiple results, so we need to hide it completely + */ + @Test + @Issue("SECURITY-904") + public void symlink_avoidLeakingInformation_aboutIllegalFolder() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + + File secretsFolder = new File(j.jenkins.getRootDir(), "secrets"); + File secretTarget = new File(secretsFolder, "goal.txt"); + String secretContent = "secret"; + FileUtils.write(secretTarget, secretContent); + FileUtils.write(new File(secretsFolder, "public_fake1.key"), secretContent); + FileUtils.write(new File(secretsFolder, "public_fake2.key"), secretContent); + FileUtils.write(new File(secretsFolder, "public_fake3.key"), secretContent); + + /* + * secrets/ + * goal.txt + * public_fake1.key + * public_fake2.key + * public_fake3.key + * workspace/ + * intermediateFolder/ + * public2.key + * otherFolder/ + * to_secret3 -> ../../../../secrets/ + * to_secret2 -> ../../../secrets/ + * to_secret_goal2 -> ../../../secrets/goal.txt + * public1.key + * to_secret1 -> ../../secrets/ + * to_secret_goal1 -> ../../secrets/goal.txt + * + */ + if (Functions.isWindows()) { + // no need to test mklink /H since we cannot create an hard link to a non-existing file + // and so you need to have access to the master file system directly which is already a problem + + String script = loadContentFromResource("outsideWorkspaceStructure.bat"); + p.getBuildersList().add(new BatchFile(script)); + } else { + String script = loadContentFromResource("outsideWorkspaceStructure.sh"); + p.getBuildersList().add(new Shell(script)); + } + + assertEquals(Result.SUCCESS, p.scheduleBuild2(0).get().getResult()); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + + // the pattern allow us to search inside the files / folders, + // but it should not provide / leak information about non readable folders + + { // without the patch the otherFolder and to_secrets[1,2,3] will appear in the results (once) + Page page = wc.goTo(p.getUrl() + "ws/**/goal.txt", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + // really not satisfying the query + not(containsString("public1.key")), + not(containsString("public2.key")), + // those following presences would have leak information that there is some file satisfying that pattern inside + not(containsString("to_secrets")), + not(containsString("to_secrets2")), + not(containsString("to_secrets3")) + )); + } + { // without the patch the otherFolder and to_secrets[1,2,3] will appear in the results (3 times each) + Page page = wc.goTo(p.getUrl() + "ws/**/public*.key", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + containsString("public1.key"), + containsString("public2.key"), + // those following presences would have leak information that there is some file satisfying that pattern inside + not(containsString("otherFolder")), + not(containsString("to_secrets")), + not(containsString("to_secrets2")), + not(containsString("to_secrets3")) + )); + } + } + + // The hard links (mklink /H) to file are impossible to be detected and will allow a user to retrieve any file in the system + // to achieve that they should already have access to the system or the Script Console. + @Test + @Issue("SECURITY-904") + public void junctionAndSymlink_outsideWorkspace_areNotAllowed_windowsJunction() throws Exception { + Assume.assumeTrue(Functions.isWindows()); + + FreeStyleProject p = j.createFreeStyleProject(); + + File secretsFolder = new File(j.jenkins.getRootDir(), "secrets"); + File secretTarget = new File(secretsFolder, "goal.txt"); + String secretContent = "secret"; + FileUtils.write(secretTarget, secretContent); + + /* + * secrets/ + * goal.txt + * workspace/ + * intermediateFolder/ + * public2.key + * otherFolder/ + * to_secret3s -> symlink ../../../../secrets/ + * to_secret3j -> junction ../../../../secrets/ + * to_secret2s -> symlink ../../../secrets/ + * to_secret2j -> junction ../../../secrets/ + * to_secret_goal2 -> symlink ../../../secrets/goal.txt + * public1.key + * to_secret1s -> symlink ../../secrets/ + * to_secret1j -> junction ../../secrets/ + * to_secret_goal1 -> symlink ../../secrets/goal.txt + * + */ + String script = loadContentFromResource("outsideWorkspaceStructureWithJunctions.bat"); + p.getBuildersList().add(new BatchFile(script)); + + assertEquals(Result.SUCCESS, p.scheduleBuild2(0).get().getResult()); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + { // workspace root must be reachable (regular case) + Page page = wc.goTo(p.getUrl() + "ws/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + containsString("public1.key"), + containsString("intermediateFolder"), + containsString("to_secrets1j"), + containsString("to_secrets1s"), + containsString("to_secrets_goal1"), + not(containsString("to_secrets2")), + not(containsString("to_secrets_goal2")) + )); + } + { // to_secrets1s not reachable + Page page = wc.goTo(p.getUrl() + "ws/to_secrets1s/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // to_secrets1j not reachable + Page page = wc.goTo(p.getUrl() + "ws/to_secrets1j/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // to_secrets_goal1 not reachable + Page page = wc.goTo(p.getUrl() + "ws/to_secrets_goal1/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // intermediateFolder must be reachable (regular case) + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + not(containsString("to_secrets1")), + not(containsString("to_secrets_goal1")), + containsString("to_secrets2s"), + containsString("to_secrets2j"), + containsString("to_secrets_goal2") + )); + } + { // to_secrets2s not reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets2s/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // to_secrets2j not reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets2j/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // using symbolic in the intermediate path + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets2s/master.key", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // using symbolic in the intermediate path + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets2j/master.key", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + { // to_secrets_goal2 not reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_secrets_goal2/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_FORBIDDEN)); + } + + // pattern search feature + { // the pattern allow us to search inside the files / folders, + // without the patch the master.key from inside the outside symlinks would have been linked + Page page = wc.goTo(p.getUrl() + "ws/**/*.key", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + not(containsString("master.key")), + containsString("public1.key"), + containsString("public2.key"), + containsString("intermediateFolder"), + not(containsString("otherFolder")), + not(containsString("to_secrets3j")), + not(containsString("to_secrets3s")), + not(containsString("to_secrets2j")), + not(containsString("to_secrets2s")), + not(containsString("to_secrets1j")), + not(containsString("to_secrets1s")) + )); + } + + // zip feature + { // all the outside folders / files are not included in the zip + Page zipPage = wc.goTo(p.getUrl() + "ws/*zip*/ws.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, containsInAnyOrder( + p.getName() + "/intermediateFolder/public2.key", + p.getName() + "/public1.key" + )); + } + { // all the outside folders / files are not included in the zip + Page zipPage = wc.goTo(p.getUrl() + "ws/intermediateFolder/*zip*/intermediateFolder.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, contains("intermediateFolder/public2.key")); + } + } + + private List getListOfEntriesInDownloadedZip(UnexpectedPage zipPage) throws Exception { + List result; + + File zipfile = null; + ZipFile readzip = null; + try { + zipfile = download(zipPage); + + readzip = new ZipFile(zipfile); + result = readzip.stream().map(ZipEntry::getName).collect(Collectors.toList()); + } + finally { + if (readzip != null) { + readzip.close(); + } + if (zipfile != null) { + zipfile.delete(); + } + } + return result; + } + + //TODO taken from existing test without modification to ease merge back + private File download(UnexpectedPage page) throws IOException { + File file = File.createTempFile("DirectoryBrowserSupport", "zipDownload"); + file.delete(); + try (InputStream is = page.getInputStream(); + OutputStream os = Files.newOutputStream(file.toPath())) { + IOUtils.copy(is, os); + } + + return file; + } + + @Test + @Issue("SECURITY-904") + public void directSymlink_forTestingZip() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + + assertEquals(Result.SUCCESS, p.scheduleBuild2(0).get().getResult()); + FilePath ws = p.getSomeWorkspace(); + + /* + * secrets/ + * goal.txt + * workspace/ + * /a1/to_secrets1 + * /b1/b2/to_secrets1 + * /c1/c2/c3/to_secrets1 + */ + File secretsFolder = new File(j.jenkins.getRootDir(), "secrets"); + FilePath a1 = ws.child("a1"); + a1.mkdirs(); + a1.child("to_secrets1").symlinkTo(secretsFolder.getAbsolutePath(), TaskListener.NULL); + FilePath b2 = ws.child("b1").child("b2"); + b2.mkdirs(); + b2.child("to_secrets2").symlinkTo(secretsFolder.getAbsolutePath(), TaskListener.NULL); + FilePath c3 = ws.child("c1").child("c2").child("c3"); + c3.mkdirs(); + c3.child("to_secrets3").symlinkTo(secretsFolder.getAbsolutePath(), TaskListener.NULL); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + { + Page zipPage = wc.goTo(p.getUrl() + "ws/*zip*/ws.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, hasSize(0)); + } + { + Page zipPage = wc.goTo(p.getUrl() + "ws/a1/*zip*/a1.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, hasSize(0)); + } + { + Page zipPage = wc.goTo(p.getUrl() + "ws/b1/b2/*zip*/b2.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, hasSize(0)); + } + { + Page zipPage = wc.goTo(p.getUrl() + "ws/c1/c2/c3/*zip*/c3.zip", null); + assertThat(zipPage.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + + List entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage); + assertThat(entryNames, hasSize(0)); + } + } + + @Test + @Issue("SECURITY-904") + public void symlink_insideWorkspace_areStillAllowed() throws Exception { + FreeStyleProject p = j.createFreeStyleProject(); + + // build once to have the workspace set up + assertEquals(Result.SUCCESS, p.scheduleBuild2(0).get().getResult()); + + File jobWorkspaceFolder = new File(new File(j.jenkins.getRootDir(), "workspace"), p.name); + File folderInsideWorkspace = new File(jobWorkspaceFolder, "asset"); + folderInsideWorkspace.mkdir(); + File fileTarget = new File(folderInsideWorkspace, "goal.txt"); + String publicContent = "not-secret"; + FileUtils.write(fileTarget, publicContent); + + /* + * workspace/ + * asset/ + * goal.txt + * intermediateFolder/ + * to_internal2 -> ../asset + * to_internal_goal2 -> ../asset/goal.txt + * to_internal1 -> ./asset/ + * to_internal_goal1 -> ./asset/goal.txt + */ + if (Functions.isWindows()) { + String script = loadContentFromResource("insideWorkspaceStructure.bat"); + p.getBuildersList().add(new BatchFile(script)); + } else { + String script = loadContentFromResource("insideWorkspaceStructure.sh"); + p.getBuildersList().add(new Shell(script)); + } + + assertEquals(Result.SUCCESS, p.scheduleBuild2(0).get().getResult()); + + JenkinsRule.WebClient wc = j.createWebClient(); + wc.getOptions().setThrowExceptionOnFailingStatusCode(false); + { // workspace root must be reachable (regular case) + Page page = wc.goTo(p.getUrl() + "ws/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, allOf( + containsString("asset"), + containsString("to_internal1"), + containsString("to_internal_goal1"), + containsString("intermediateFolder"), + not(containsString("to_internal2")), + not(containsString("to_internal_goal2") + ))); + } + { // to_internal1 reachable + Page page = wc.goTo(p.getUrl() + "ws/to_internal1/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, containsString("goal.txt")); + } + { // to_internal_goal1 reachable + Page page = wc.goTo(p.getUrl() + "ws/to_internal_goal1/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, containsString(publicContent)); + } + { // to_internal2 reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_internal2/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, containsString("goal.txt")); + } + { // to_internal_goal2 reachable + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/to_internal_goal2/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, containsString(publicContent)); + } + { // direct to goal + Page page = wc.goTo(p.getUrl() + "ws/asset/goal.txt/", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + String workspaceContent = page.getWebResponse().getContentAsString(); + assertThat(workspaceContent, containsString(publicContent)); + } + { // the zip will only contain folder from inside the workspace + Page page = wc.goTo(p.getUrl() + "ws/*zip*/ws.zip", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + } + { // the zip will only contain folder from inside the workspace + Page page = wc.goTo(p.getUrl() + "ws/intermediateFolder/*zip*/intermediateFolder.zip", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + } + { // the zip will only contain folder from inside the workspace + Page page = wc.goTo(p.getUrl() + "ws/asset/*zip*/asset.zip", null); + assertThat(page.getWebResponse().getStatusCode(), equalTo(HttpURLConnection.HTTP_OK)); + } + } + + private String loadContentFromResource(String fileNameInResources) throws IOException, URISyntaxException { + URL resourceUrl = DirectoryBrowserSupportSEC904Test.class.getResource(DirectoryBrowserSupportSEC904Test.class.getSimpleName() + "/" + fileNameInResources); + if (resourceUrl == null) { + fail("The resource with fileName " + fileNameInResources + " is not present in the resources of the test"); + } + File resourceFile = new File(resourceUrl.toURI()); + return FileUtils.readFileToString(resourceFile); + } +} diff --git a/test/src/test/java/hudson/tasks/ArtifactArchiverTest.java b/test/src/test/java/hudson/tasks/ArtifactArchiverTest.java index d64e7a1570..7ceccf5a23 100644 --- a/test/src/test/java/hudson/tasks/ArtifactArchiverTest.java +++ b/test/src/test/java/hudson/tasks/ArtifactArchiverTest.java @@ -163,7 +163,7 @@ public class ArtifactArchiverTest { assertFalse(kids[0].isDirectory()); assertFalse(kids[0].isFile()); assertFalse(kids[0].exists()); - j.createWebClient().assertFails(b.getUrl() + "artifact/hack", HttpURLConnection.HTTP_NOT_FOUND); + j.createWebClient().assertFails(b.getUrl() + "artifact/hack", HttpURLConnection.HTTP_FORBIDDEN); } static class CreateArtifact extends TestBuilder { diff --git a/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.bat b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.bat new file mode 100644 index 0000000000..7e4b744d4a --- /dev/null +++ b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.bat @@ -0,0 +1,7 @@ +mklink /D to_internal1 .\asset +mklink to_internal_goal1 .\asset\goal.txt + +mkdir intermediateFolder +cd intermediateFolder +mklink /D to_internal2 ..\asset +mklink to_internal_goal2 ..\asset\goal.txt diff --git a/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.sh b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.sh new file mode 100644 index 0000000000..475b24f3fe --- /dev/null +++ b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/insideWorkspaceStructure.sh @@ -0,0 +1,8 @@ +#!/bin/sh +ln -s ./asset to_internal1 +ln -s ./asset/goal.txt to_internal_goal1 + +mkdir intermediateFolder +cd intermediateFolder +ln -s ../asset to_internal2 +ln -s ../asset/goal.txt to_internal_goal2 diff --git a/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.bat b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.bat new file mode 100644 index 0000000000..6159b02242 --- /dev/null +++ b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.bat @@ -0,0 +1,13 @@ +echo public-1 > public1.key +mklink /D to_secrets1 ..\..\secrets +mklink to_secrets_goal1 ..\..\secrets\goal.txt + +mkdir intermediateFolder +cd intermediateFolder +echo public-2 > public2.key +mklink /D to_secrets2 ..\..\..\secrets +mklink to_secrets_goal2 ..\..\..\secrets\goal.txt + +mkdir otherFolder +cd otherFolder +mklink /D to_secrets3 ..\..\..\..\secrets diff --git a/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.sh b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.sh new file mode 100644 index 0000000000..d154f42c67 --- /dev/null +++ b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructure.sh @@ -0,0 +1,14 @@ +#!/bin/sh +echo 'public-1' > public1.key +ln -s ../../secrets to_secrets1 +ln -s ../../secrets/goal.txt to_secrets_goal1 + +mkdir intermediateFolder +cd intermediateFolder +echo 'public-2' > public2.key +ln -s ../../../secrets to_secrets2 +ln -s ../../../secrets/goal.txt to_secrets_goal2 + +mkdir otherFolder +cd otherFolder +ln -s ../../../../secrets to_secrets3 diff --git a/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructureWithJunctions.bat b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructureWithJunctions.bat new file mode 100644 index 0000000000..58ed542e74 --- /dev/null +++ b/test/src/test/resources/hudson/model/DirectoryBrowserSupportSEC904Test/outsideWorkspaceStructureWithJunctions.bat @@ -0,0 +1,16 @@ +echo public-1 > public1.key +mklink /D to_secrets1s ..\..\secrets +mklink /J to_secrets1j ..\..\secrets +mklink to_secrets_goal1 ..\..\secrets\goal.txt + +mkdir intermediateFolder +cd intermediateFolder +echo public-2 > public2.key +mklink /D to_secrets2s ..\..\..\secrets +mklink /J to_secrets2j ..\..\..\secrets +mklink to_secrets_goal2 ..\..\..\secrets\goal.txt + +mkdir otherFolder +cd otherFolder +mklink /D to_secrets3s ..\..\..\..\secrets +mklink /J to_secrets3j ..\..\..\..\secrets