mirror of
https://github.com/jenkinsci/jenkins.git
synced 2026-11-09 18:16:10 +00:00
[SECURITY-904]
This commit is contained in:
1 parent
82f55553ca
commit
c19cc70568
14 files changed
+1509
-39
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<List<Path>> 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<List<Path>> filteredGlob = keepReadabilityOnlyOnDescendants(baseFile, patternUsed, glob);
|
||||
|
||||
// serve glob
|
||||
req.setAttribute("it", this);
|
||||
List<Path> 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<List<Path>> keepReadabilityOnlyOnDescendants(VirtualFile root, boolean patternUsed, List<List<Path>> pathFragmentsList){
|
||||
Stream<List<Path>> pathFragmentsStream = pathFragmentsList.stream().map((List<Path> pathFragments) -> {
|
||||
List<Path> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1151,7 +1151,7 @@ public abstract class Run <JobT extends Job<JobT,RunT>,RunT extends Run<JobT,Run
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Maximum number of artifacts to list before using switching to the tree view.
|
||||
*/
|
||||
|
||||
@@ -42,12 +42,16 @@ import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
import jenkins.MasterToSlaveFileCallable;
|
||||
import org.kohsuke.accmod.Restricted;
|
||||
import org.kohsuke.accmod.restrictions.NoExternalUse;
|
||||
|
||||
/**
|
||||
* Abstraction over {@link File}, {@link FilePath}, or other items such as network resources or ZIP entries.
|
||||
@@ -208,6 +212,27 @@ public abstract class VirtualFile implements Comparable<VirtualFile>, 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<VirtualFile>, 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<VirtualFile>, 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<String> 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<VirtualFile>, 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<VirtualFile>, Serializab
|
||||
List<FilePath> 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<VirtualFile>, 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<VirtualFile>, 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<String> 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<String[]> {
|
||||
private final String glob;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String> 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<String> 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<String> entryNames = getListOfEntriesInDownloadedZip((UnexpectedPage) zipPage);
|
||||
assertThat(entryNames, contains("intermediateFolder/public2.key"));
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> getListOfEntriesInDownloadedZip(UnexpectedPage zipPage) throws Exception {
|
||||
List<String> 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<String> 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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+7
@@ -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
|
||||
+8
@@ -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
|
||||
+13
@@ -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
|
||||
+14
@@ -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
|
||||
+16
@@ -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
|
||||
Reference in new issue
Block a user