Security context
Medium· 6.5GHSA-vpjm-58cw-r8q5 CVE-2021-21602CWE-59Published May 24, 2022

Arbitrary file read vulnerability in workspace browsers in Jenkins

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

0 → fixed in 2.263.22.264 → fixed in 2.275

Details

The file browser for workspaces, archived artifacts, and `$JENKINS_HOME/userContent/` follows symbolic links to locations outside the directory being browsed in Jenkins 2.274 and earlier, LTS 2.263.1 and earlier. This allows attackers with Job/Workspace permission and the ability to control workspace contents (e.g., with Job/Configure permission or the ability to change SCM contents) to create symbolic links that allow them to access files outside workspaces using the workspace browser. This issue is caused by an incomplete fix for SECURITY-904 / CVE-2018-1000862 in the [2018-12-08 security advisory](https://www.jenkins.io/security/advisory/2018-12-05/#SECURITY-904). Jenkins 2.275, LTS 2.263.2 no longer supports symlinks in workspace browsers. While they may still exist on the file system, they are no longer shown on the UI, accessible via URLs, or included in directory content downloads. This fix only changes the behavior of the Jenkins UI. Archiving artifacts still behaves as before.

The fix

[SECURITY-1452]

Jeff Thompson· Jan 11, 2021, 04:52 PM+9555071d2ecf1a4
core/src/main/java/hudson/FilePath.java+257 9
@@ -83,9 +83,10 @@
import java.nio.file.FileSystemException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
+import java.nio.file.LinkOption;
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;
@@ -474,6 +475,25 @@ public int zip(OutputStream out, DirScanner scanner) throws IOException, Interru
return archive(ArchiverFactory.ZIP, out, scanner);
}
+ /**
+ * Uses the given scanner on 'this' directory to list up files and then archive it to a zip stream.
+ *
+ * @param out The OutputStream to write the zip into.
+ * @param scanner A DirScanner for scanning the directory and filtering its contents.
+ * @param verificationRoot A root or base directory for checking for any symlinks in this files parentage.
+ * Any symlinks between a file and root should be ignored.
+ * Symlinks in the parentage outside root will not be checked.
+ * @param noFollowLinks true if it should not follow links.
+ *
+ * @return The number of files/directories archived.
+ * This is only really useful to check for a situation where nothing
+ */
+ @Restricted(NoExternalUse.class)
+ public int zip(OutputStream out, DirScanner scanner, String verificationRoot, boolean noFollowLinks) throws IOException, InterruptedException {
+ ArchiverFactory archiverFactory = noFollowLinks ? ArchiverFactory.ZIP_WTHOUT_FOLLOWING_SYMLINKS : ArchiverFactory.ZIP;
+ return archive(archiverFactory, out, scanner, verificationRoot, noFollowLinks);
+ }
+
/**
* Archives this directory into the specified archive format, to the given {@link OutputStream}, by using
* {@link DirScanner} to choose what files to include.
@@ -483,23 +503,48 @@ public int zip(OutputStream out, DirScanner scanner) throws IOException, Interru
* is archived.
*/
public int archive(final ArchiverFactory factory, OutputStream os, final DirScanner scanner) throws IOException, InterruptedException {
+ return archive(factory, os, scanner, null, false);
+ }
+
+ /**
+ * Archives this directory into the specified archive format, to the given {@link OutputStream}, by using
+ * {@link DirScanner} to choose what files to include.
+ *
+ * @param factory The ArchiverFactory for creating the archive.
+ * @param os The OutputStream to write the zip into.
+ * @param verificationRoot A root or base directory for checking for any symlinks in this files parentage.
+ * Any symlinks between a file and root should be ignored.
+ * Symlinks in the parentage outside root will not be checked.
+ * @param noFollowLinks true if it should not follow links.
+ *
+ * @return The number of files/directories archived.
+ * This is only really useful to check for a situation where nothing
+ */
+ @Restricted(NoExternalUse.class)
+ public int archive(final ArchiverFactory factory, OutputStream os, final DirScanner scanner,
+ String verificationRoot, boolean noFollowLinks) throws IOException, InterruptedException {
final OutputStream out = (channel!=null)?new RemoteOutputStream(os):os;
- return act(new Archive(factory, out, scanner));
+ return act(new Archive(factory, out, scanner, verificationRoot, noFollowLinks));
}
private class Archive extends SecureFileCallable<Integer> {
private final ArchiverFactory factory;
private final OutputStream out;
private final DirScanner scanner;
- Archive(ArchiverFactory factory, OutputStream out, DirScanner scanner) {
+ private final String verificationRoot;
+ private final boolean noFollowLinks;
+
+ Archive(ArchiverFactory factory, OutputStream out, DirScanner scanner, String verificationRoot, boolean noFollowLinks) {
this.factory = factory;
this.out = out;
this.scanner = scanner;
+ this.verificationRoot = verificationRoot;
+ this.noFollowLinks = noFollowLinks;
}
@Override
public Integer invoke(File f, VirtualChannel channel) throws IOException {
Archiver a = factory.create(out);
try {
- scanner.scan(f,reading(a));
+ scanner.scan(f, ignoringSymlinks(reading(a), verificationRoot, noFollowLinks));
} finally {
a.close();
}
@@ -691,6 +736,46 @@ public String invoke(File f, VirtualChannel channel) throws IOException {
}
}
+ @Restricted(NoExternalUse.class)
+ public boolean hasSymlink(FilePath verificationRoot, boolean noFollowLinks) throws IOException, InterruptedException {
+ return act(new HasSymlink(verificationRoot == null ? null : verificationRoot.remote, noFollowLinks));
+ }
+ private static class HasSymlink extends SecureFileCallable<Boolean> {
+ private static final long serialVersionUID = 1L;
+ private final String verificationRoot;
+ private final boolean noFollowLinks;
+
+ public HasSymlink(String verificationRoot, boolean noFollowLinks) {
+ this.verificationRoot = verificationRoot;
+ this.noFollowLinks = noFollowLinks;
+ }
+
+ public Boolean invoke(File f, VirtualChannel channel) throws IOException {
+ return isSymlink(f, verificationRoot, noFollowLinks);
+ }
+ }
+
+ @Restricted(NoExternalUse.class)
+ public boolean containsSymlink(FilePath verificationRoot, boolean noFollowLinks) throws IOException, InterruptedException {
+ return !list(new SymlinkRetainingFileFilter(verificationRoot, noFollowLinks)).isEmpty();
+ }
+
+ private static class SymlinkRetainingFileFilter implements FileFilter, Serializable {
+
+ private final String verificationRoot;
+ private final boolean noFollowLinks;
+
+ public SymlinkRetainingFileFilter(FilePath verificationRoot, boolean noFollowLinks) {
+ this.verificationRoot = verificationRoot == null ? null : verificationRoot.remote;
+ this.noFollowLinks = noFollowLinks;
+ }
+
+ public boolean accept(File file) {
+ return isSymlink(file, verificationRoot, noFollowLinks);
+ }
+ private static final long serialVersionUID = 1L;
+ }
+
/**
* Creates a symlink to the specified target.
*
@@ -1838,6 +1923,23 @@ public List<FilePath> list() throws IOException, InterruptedException {
return list((FileFilter)null);
}
+ /**
+ * List up files and directories in this directory.
+ *
+ * This is intended to allow the caller to provide {@link java.nio.file.LinkOption#NOFOLLOW_LINKS} to ignore
+ * symlinks.
+ * @param verificationRoot A root or base directory for checking for any symlinks in this files parentage.
+ * Any symlinks between a file and root should be ignored.
+ * Symlinks in the parentage outside root will not be checked.
+ * @param noFollowLinks true if it should not follow links.
+ * @return Direct children of this directory.
+ */
+ @Restricted(NoExternalUse.class)
+ @NonNull
+ public List<FilePath> list(FilePath verificationRoot, boolean noFollowLinks) throws IOException, InterruptedException {
+ return list(new SymlinkDiscardingFileFilter(verificationRoot, noFollowLinks));
+ }
+
/**
* List up subdirectories.
*
@@ -1982,25 +2084,112 @@ private static String[] glob(File dir, String includes, String excludes, boolean
* Reads this file.
*/
public InputStream read() throws IOException, InterruptedException {
+ return read(null, false);
+ }
+
+ @Restricted(NoExternalUse.class)
+ public InputStream read(FilePath rootPath, boolean noFollowLinks) throws IOException, InterruptedException {
+ String rootPathString = rootPath == null ? null : rootPath.remote;
if(channel==null) {
- return Files.newInputStream(fileToPath(reading(new File(remote))));
+ File file = reading(new File(remote));
+ InputStream inputStream = newInputStreamDenyingSymlinkAsNeeded(file, rootPathString, noFollowLinks);
+ return inputStream;
}
final Pipe p = Pipe.createRemoteToLocal();
- actAsync(new Read(p));
+ actAsync(new Read(p, rootPathString, noFollowLinks));
return p.getIn();
}
+
+ @Restricted(NoExternalUse.class)
+ public static InputStream newInputStreamDenyingSymlinkAsNeeded(File file, String verificationRoot, boolean noFollowLinks) throws IOException {
+ InputStream inputStream = null;
+ try {
+ denySymlink(file, verificationRoot, noFollowLinks);
+ inputStream = noFollowLinks ? Files.newInputStream(fileToPath(file), LinkOption.NOFOLLOW_LINKS) : Files.newInputStream(fileToPath(file));
+ denySymlink(file, verificationRoot, noFollowLinks);
+ } catch (IOException ioe) {
+ if (inputStream != null) {
+ inputStream.close();
+ }
+ throw ioe;
+ }
+ return inputStream;
+ }
+
+ private static void denySymlink(File file, String root, boolean noFollowLinks) throws IOException {
+ /* This should be checked right before the file is opened or otherwise traversed.
+ If at all possible, it should also be checked immediately afterwards.
+ This narrows any possible race conditions that may exist in weird situations,
+ platforms, or implementations.
+ newInputStreamDenyingSymlinkAsNeeded(...) demonstrates how this would be done.
+
+ This is useful for preventing symlink following on systems that don't support
+ LinkOption.NOFOLLOW_LINK. Notable among those is AIX. It is also important for
+ prohibiting Windows Junctions, which are not considered symlinks by the
+ Files.newInputStream(path, LinkOption.NOFOLLOW_LINKS) implementation.
+ */
+
+ if (isSymlink(file, root, noFollowLinks)) {
+ throw new IOException("Symlinks are prohibited.");
+ }
+ }
+
+ @Restricted(NoExternalUse.class)
+ public static boolean isSymlink(File file, String root, boolean noFollowLinks) {
+ if (noFollowLinks) {
+ if (Util.isSymlink(file.toPath())) {
+ return true;
+ }
+
+ return isFileAncestorSymlink(file, root);
+ }
+ return false;
+ }
+
+ /**
+ * Determines whether an ancestor of this file is a symlink, between the specified
+ * file and the root path. Ancestors further up the tree are not considered.
+ * @param file The base file for the beginning of the search.
+ * @param root The root path for ending the search.
+ * @return True if there is a symlink within the domain. False otherwise.
+ */
+ private static boolean isFileAncestorSymlink(File file, String root) {
+ if (root != null) {
+ Path rootPath = Paths.get(root);
+ Path currPath = file.toPath();
+ try {
+ while (!getRealPath(currPath).equals(getRealPath(rootPath))) {
+ if (Util.isSymlink(currPath)) {
+ return true;
+ }
+ currPath = currPath.getParent();
+ if (currPath == null) {
+ return false;
+ }
+ }
+ } catch (IOException ioe) {
+ return false;
+ }
+ }
+ return false;
+ }
+
private class Read extends SecureFileCallable<Void> {
private static final long serialVersionUID = 1L;
private final Pipe p;
- Read(Pipe p) {
+ private String verificationRoot;
+ private boolean noFollowLinks;
+
+ Read(Pipe p, String verificationRoot, boolean noFollowLinks) {
this.p = p;
+ this.verificationRoot = verificationRoot;
+ this.noFollowLinks = noFollowLinks;
}
@Override
public Void invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
- try (InputStream fis = Files.newInputStream(fileToPath(reading(f)));
- OutputStream out = p.getOut()) {
+ try (InputStream fis = newInputStreamDenyingSymlinkAsNeeded(reading(f), verificationRoot, noFollowLinks); OutputStream out = p.getOut()) {
org.apache.commons.io.IOUtils.copy(fis, out);
} catch (Exception x) {
p.error(x);
@@ -3249,6 +3438,29 @@ public boolean understandsSymlink() {
};
}
+ /**
+ * Wraps {@link FileVisitor} to ignore symlinks.
+ */
+ @Restricted(NoExternalUse.class)
+ public static FileVisitor ignoringSymlinks(final FileVisitor v, String verificationRoot, boolean noFollowLinks) {
+ if (noFollowLinks) {
+ return new FileVisitor() {
+ @Override
+ public void visit(File f, String relativePath) throws IOException {
+ if (verificationRoot == null || !FilePath.isSymlink(f, verificationRoot, noFollowLinks)) {
+ v.visit(f, relativePath);
+ }
+ }
+
+ @Override
+ public boolean understandsSymlink() {
+ return false;
+ }
+ };
+ }
+ return v;
+ }
+
/**
* Pass through 'f' after ensuring that we can read that file.
*/
@@ -3429,5 +3641,41 @@ public Boolean invoke(@NonNull File parentFile, @NonNull VirtualChannel channel)
}
}
+ private static Path getRealPath(Path path) throws IOException {
+ return Functions.isWindows() ? windowsToRealPath(path) : path.toRealPath();
+ }
+
+ private static @NonNull Path windowsToRealPath(@NonNull Path path) throws IOException {
+ try {
+ return path.toRealPath();
+ }
+ catch (IOException e) {
+ if (LOGGER.isLoggable(Level.FINE)) {
+ LOGGER.log(Level.FINE, String.format("relaxedToRealPath cannot use the regular toRealPath on %s, trying with toRealPath(LinkOption.NOFOLLOW_LINKS)", path), e);
+ }
+ }
+
+ // that's required for specific environment like Windows Server 2016, running MSFT docker
+ // where the root is a <SYMLINKD>
+ return path.toRealPath(LinkOption.NOFOLLOW_LINKS);
+ }
+
private static final SoloFilePathFilter UNRESTRICTED = SoloFilePathFilter.wrap(FilePathFilter.UNRESTRICTED);
+
+ private static class SymlinkDiscardingFileFilter implements FileFilter, Serializable {
+
+ private final String verificationRoot;
+ private final boolean noFollowLinks;
+
+ public SymlinkDiscardingFileFilter(FilePath verificationRoot, boolean noFollowLinks) {
+ this.verificationRoot = verificationRoot == null ? null : verificationRoot.remote;
+ this.noFollowLinks = noFollowLinks;
+ }
+
+ public boolean accept(File file) {
+ return !isSymlink(file, verificationRoot, noFollowLinks);
+ }
+ private static final long serialVersionUID = 1L;
+ }
+
}
core/src/main/java/hudson/model/DirectoryBrowserSupport.java+29 17
@@ -61,6 +61,7 @@
import org.apache.commons.io.IOUtils;
import org.apache.tools.zip.ZipEntry;
import org.apache.tools.zip.ZipOutputStream;
+import org.apache.commons.lang.StringUtils;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
@@ -240,25 +241,23 @@ private void serveFile(StaplerRequest req, StaplerResponse rsp, VirtualFile root
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 = base.isEmpty() ? root : root.child(base);
+ if (baseFile.hasSymlink(getNoFollowLinks())) {
+ rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
if(baseFile.isDirectory()) {
if(zip) {
rsp.setContentType("application/zip");
- zip(rsp, root, baseFile, rest);
+ baseFile.zip(rsp.getOutputStream(), StringUtils.isBlank(rest) ? "**" : rest, null, true, getNoFollowLinks());
return;
}
if (plain) {
rsp.setContentType("text/plain;charset=UTF-8");
try (OutputStream os = rsp.getOutputStream()) {
- for (VirtualFile kid : baseFile.list()) {
+ for (VirtualFile kid : baseFile.list(getNoFollowLinks())) {
os.write(kid.getName().getBytes(StandardCharsets.UTF_8));
if (kid.isDirectory()) {
os.write('/');
@@ -281,29 +280,30 @@ private void serveFile(StaplerRequest req, StaplerResponse rsp, VirtualFile root
List<List<Path>> glob = null;
boolean patternUsed = rest.length() > 0;
+ boolean containsSymlink = false;
if(patternUsed) {
// the rest is Ant glob pattern
glob = patternScan(baseFile, rest, createBackRef(restSize));
} else
if(serveDirIndex) {
// serve directory index
- glob = baseFile.run(new BuildChildPaths(baseFile, req.getLocale()));
+ glob = baseFile.run(new BuildChildPaths(root, baseFile, req.getLocale()));
+ containsSymlink = baseFile.containsSymLinkChild(getNoFollowLinks());
}
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", filteredGlob);
+ req.setAttribute("files", glob);
req.setAttribute("icon", icon);
req.setAttribute("path", path);
req.setAttribute("pattern",rest);
req.setAttribute("dir", baseFile);
+ req.setAttribute("showSymlinkWarning", containsSymlink);
if (ResourceDomainConfiguration.isResourceRequest(req)) {
req.getView(this, "plaindir.jelly").forward(req, rsp);
} else {
@@ -346,12 +346,19 @@ private void serveFile(StaplerRequest req, StaplerResponse rsp, VirtualFile root
if(LOGGER.isLoggable(Level.FINE))
LOGGER.fine("Serving "+baseFile+" with lastModified=" + lastModified + ", length=" + length);
+ InputStream in;
+ try {
+ in = baseFile.open(getNoFollowLinks());
+ } catch (IOException ioe) {
+ rsp.sendError(HttpServletResponse.SC_NOT_FOUND);
+ return;
+ }
if (view) {
// for binary files, provide the file name for download
rsp.setHeader("Content-Disposition", "inline; filename=" + baseFile.getName());
// pseudo file name to let the Stapler set text/plain
- rsp.serveFile(req, baseFile.open(), lastModified, -1, length, "plain.txt");
+ rsp.serveFile(req, in, lastModified, -1, length, "plain.txt");
} else {
if (resourceToken != null) {
// redirect to second domain
@@ -675,9 +682,11 @@ private int dirRank(VirtualFile f) {
}
private static final class BuildChildPaths extends MasterToSlaveCallable<List<List<Path>>,IOException> {
+ private VirtualFile root;
private final VirtualFile cur;
private final Locale locale;
- BuildChildPaths(VirtualFile cur, Locale locale) {
+ BuildChildPaths(VirtualFile root, VirtualFile cur, Locale locale) {
+ this.root = root;
this.cur = cur;
this.locale = locale;
}
@@ -693,7 +702,7 @@ private static final class BuildChildPaths extends MasterToSlaveCallable<List<Li
private static List<List<Path>> buildChildPaths(VirtualFile cur, Locale locale) throws IOException {
List<List<Path>> r = new ArrayList<>();
- VirtualFile[] files = cur.list();
+ VirtualFile[] files = cur.list(getNoFollowLinks());
Arrays.sort(files,new FileComparator(locale));
for( VirtualFile f : files ) {
@@ -708,7 +717,7 @@ private static List<List<Path>> buildChildPaths(VirtualFile cur, Locale locale)
while(true) {
// files that don't start with '.' qualify for 'meaningful files', nor SCM related files
List<VirtualFile> sub = new ArrayList<>();
- for (VirtualFile vf : f.list()) {
+ for (VirtualFile vf : f.list(getNoFollowLinks())) {
String name = vf.getName();
if (!name.startsWith(".") && !name.equals("CVS") && !name.equals(".svn")) {
sub.add(vf);
@@ -733,7 +742,7 @@ private static List<List<Path>> buildChildPaths(VirtualFile cur, Locale locale)
* @param baseRef String like "../../../" that cancels the 'rest' portion. Can be "./"
*/
private static List<List<Path>> patternScan(VirtualFile baseDir, String pattern, String baseRef) throws IOException {
- Collection<String> files = baseDir.list(pattern, null, /* TODO what is the user expectation? */true);
+ Collection<String> files = baseDir.list(pattern, null, /* TODO what is the user expectation? */true, getNoFollowLinks());
if (!files.isEmpty()) {
List<List<Path>> r = new ArrayList<>(files.size());
@@ -776,6 +785,9 @@ private static void buildPathList(VirtualFile baseDir, VirtualFile filePath, Lis
pathList.add(path);
}
+ private static boolean getNoFollowLinks() {
+ return !ALLOW_SYMLINK_ESCAPE;
+ }
private static final Logger LOGGER = Logger.getLogger(DirectoryBrowserSupport.class.getName());
core/src/main/java/hudson/util/DirScanner.java+16 7
@@ -6,6 +6,8 @@
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.types.FileSet;
import org.apache.tools.ant.types.selectors.FileSelector;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
import java.io.File;
import java.io.FileFilter;
@@ -103,15 +105,25 @@ public static class Glob extends DirScanner {
private final String includes, excludes;
private boolean useDefaultExcludes = true;
+ private final boolean followSymlinks;
public Glob(String includes, String excludes) {
- this.includes = includes;
- this.excludes = excludes;
+ this(includes, excludes, true, true);
}
public Glob(String includes, String excludes, boolean useDefaultExcludes) {
- this(includes, excludes);
+ this(includes, excludes, useDefaultExcludes, true);
+ }
+
+ /**
+ * @since TODO
+ */
+ @Restricted(NoExternalUse.class)
+ public Glob(String includes, String excludes, boolean useDefaultExcludes, boolean followSymlinks) {
+ this.includes = includes;
+ this.excludes = excludes;
this.useDefaultExcludes = useDefaultExcludes;
+ this.followSymlinks = followSymlinks;
}
public void scan(File dir, FileVisitor visitor) throws IOException {
@@ -122,14 +134,11 @@ public void scan(File dir, FileVisitor visitor) throws IOException {
}
FileSet fs = Util.createFileSet(dir,includes,excludes);
+ fs.setFollowSymlinks(followSymlinks);
fs.setDefaultexcludes(useDefaultExcludes);
- fs.appendSelector(new DescendantFileSelector(fs.getDir()));
-
if(dir.exists()) {
DirectoryScanner ds = fs.getDirectoryScanner(new org.apache.tools.ant.Project());
- // due to the DescendantFileSelector usage,
- // the includedFiles are only the ones that are descendant
for( String f : ds.getIncludedFiles()) {
File file = new File(dir, f);
scanSingle(file, f, visitor);
core/src/main/java/hudson/util/io/ArchiverFactory.java+15 0
@@ -25,6 +25,8 @@
package hudson.util.io;
import hudson.FilePath.TarCompression;
+import org.kohsuke.accmod.Restricted;
+import org.kohsuke.accmod.restrictions.NoExternalUse;
import java.io.IOException;
import java.io.OutputStream;
@@ -57,6 +59,11 @@ public abstract class ArchiverFactory implements Serializable {
*/
public static ArchiverFactory ZIP = new ZipArchiverFactory();
+ /**
+ * Zip format, without following symlinks.
+ */
+ @Restricted(NoExternalUse.class)
+ public static ArchiverFactory ZIP_WTHOUT_FOLLOWING_SYMLINKS = new ZipWithoutSymLinksArchiverFactory();
private static final class TarArchiverFactory extends ArchiverFactory {
@@ -81,5 +88,13 @@ public Archiver create(OutputStream out) {
private static final long serialVersionUID = 1L;
}
+ private static final class ZipWithoutSymLinksArchiverFactory extends ArchiverFactory {
+ public Archiver create(OutputStream out) {
+ return new ZipArchiver(out, true);
+ }
+
+ private static final long serialVersionUID = 1L;
+ }
+
private static final long serialVersionUID = 1L;
}
core/src/main/java/hudson/util/io/ZipArchiver.java+9 1
@@ -36,6 +36,8 @@
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
/**
* {@link FileVisitor} that creates a zip archive.
@@ -45,9 +47,15 @@
final class ZipArchiver extends Archiver {
private final byte[] buf = new byte[8192];
private final ZipOutputStream zip;
+ private final OpenOption[] openOptions;
ZipArchiver(OutputStream out) {
+ this(out, false);
+ }
+
+ ZipArchiver(OutputStream out, boolean failOnSymLink) {
zip = new ZipOutputStream(out);
+ openOptions = failOnSymLink ? new LinkOption[]{LinkOption.NOFOLLOW_LINKS} : new OpenOption[0];
zip.setEncoding(System.getProperty("file.encoding"));
zip.setUseZip64(Zip64Mode.AsNeeded);
}
@@ -73,7 +81,7 @@ public void visit(final File f, final String _relativePath) throws IOException {
if (mode!=-1) fileZipEntry.setUnixMode(mode);
fileZipEntry.setTime(f.lastModified());
zip.putNextEntry(fileZipEntry);
- try (InputStream in = Files.newInputStream(f.toPath())) {
+ try (InputStream in = Files.newInputStream(f.toPath(), openOptions)) {
int len;
while((len=in.read(buf))>=0)
zip.write(buf,0,len);
core/src/main/java/jenkins/util/VirtualFile.java+241 15
@@ -39,6 +39,7 @@
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
+import java.io.OutputStream;
import java.io.Serializable;
import java.net.URI;
import java.net.URL;
@@ -57,6 +58,8 @@
import edu.umd.cs.findbugs.annotations.CheckForNull;
import edu.umd.cs.findbugs.annotations.NonNull;
+import hudson.util.io.Archiver;
+import hudson.util.io.ArchiverFactory;
import jenkins.MasterToSlaveFileCallable;
import jenkins.model.ArtifactManager;
import jenkins.security.MasterToSlaveCallable;
@@ -173,10 +176,44 @@ public abstract class VirtualFile implements Comparable<VirtualFile>, Serializab
*/
public abstract @NonNull VirtualFile[] list() throws IOException;
+ /**
+ * Lists children of this directory. Only one level deep.
+ *
+ * This is intended to allow the caller to provide {@link java.nio.file.LinkOption#NOFOLLOW_LINKS} to ignore
+ * symlinks. However, this cannot be enforced. The base implementation here in VirtualFile ignores the openOptions.
+ * Some VirtualFile subclasses may not be able to provide
+ * an implementation in which NOFOLLOW_LINKS is used or makes sense. Implementations are free
+ * to ignore openOptions. Some subclasses of VirtualFile may not have a concept of symlinks.
+ * @param noFollowLinks if true then do not follow links.
+ * @return a list of children (files and subdirectories); empty for a file or nonexistent directory
+ * @throws IOException if it could not be opened
+ */
+ @Restricted(NoExternalUse.class)
+ public @NonNull VirtualFile[] list(boolean noFollowLinks) throws IOException {
+ return list();
+ }
+
@Restricted(NoExternalUse.class)
public boolean supportsQuickRecursiveListing() {
return false;
}
+
+ /**
+ * Determines when a VirtualFile has a recognized symlink.
+ * A recognized symlink can be the file itself or any containing directory between
+ * it and the optional root directory. If there is no provided root directory then
+ * only the file itself is considered.
+ *
+ * This base implementation ignores the existence of symlinks.
+ * @param noFollowLinks if true, then do not follow links.
+ * @return True if the file is a symlink or is referenced within a containing symlink
+ * directory before reaching the root directory.
+ * @throws IOException If there is a problem accessing the file.
+ */
+ @Restricted(NoExternalUse.class)
+ public boolean hasSymlink(boolean noFollowLinks) throws IOException {
+ return false;
+ }
/**
* Lists only the children that are descendant of the root directory (not necessarily the current VirtualFile).
@@ -218,6 +255,30 @@ public boolean supportsQuickRecursiveListing() {
* @since 2.118
*/
public @NonNull Collection<String> list(@NonNull String includes, @CheckForNull String excludes, boolean useDefaultExcludes) throws IOException {
+ return list(includes, excludes, useDefaultExcludes, false);
+ }
+
+ /**
+ * Lists recursive files of this directory with pattern matching.
+ *
+ * <p>The default implementation calls {@link #list()} recursively inside {@link #run} and applies filtering to the result.
+ * Implementations may wish to override this more efficiently.
+
+ * This method allows the user to specify that symlinks should not be followed by passing
+ * noFollowLinks as true. However, some implementations may not be able to reliably
+ * prevent link following. The base implementation here in VirtualFile ignores this parameter.
+ * @param includes comma-separated Ant-style globs as per {@link Util#createFileSet(File, String, String)} using {@code /} as a path separator;
+ * the empty string means <em>no matches</em> (use {@link SelectorUtils#DEEP_TREE_MATCH} if you want to match everything except some excludes)
+ * @param excludes optional excludes in similar format to {@code includes}
+ * @param useDefaultExcludes as per {@link AbstractFileSet#setDefaultexcludes}
+ * @param noFollowLinks if true then do not follow links.
+ * @return a list of {@code /}-separated relative names of children (files directly inside or in subdirectories)
+ * @throws IOException if this is not a directory, or listing was not possible for some other reason
+ * @since TODO
+ */
+ @Restricted(NoExternalUse.class)
+ public @NonNull Collection<String> list(@NonNull String includes, @CheckForNull String excludes, boolean useDefaultExcludes,
+ boolean noFollowLinks) throws IOException {
Collection<String> r = run(new CollectFiles(this));
List<TokenizedPattern> includePatterns = patterns(includes);
List<TokenizedPattern> excludePatterns = patterns(excludes);
@@ -231,6 +292,12 @@ public boolean supportsQuickRecursiveListing() {
return includePatterns.stream().anyMatch(patt -> patt.matchPath(path, true)) && excludePatterns.stream().noneMatch(patt -> patt.matchPath(path, true));
}).collect(Collectors.toSet());
}
+
+ @Restricted(NoExternalUse.class)
+ public boolean containsSymLinkChild(boolean noFollowLinks) throws IOException {
+ return false;
+ }
+
private static final class CollectFiles extends MasterToSlaveCallable<Collection<String>, IOException> {
private static final long serialVersionUID = 1;
private final VirtualFile root;
@@ -266,6 +333,15 @@ private List<TokenizedPattern> patterns(String patts) {
return r;
}
+ /**
+ * @since TODO
+ */
+ @Restricted(NoExternalUse.class)
+ public int zip(OutputStream outputStream, String includes, String excludes, boolean useDefaultExcludes,
+ boolean noFollowLinks) throws IOException, UnsupportedOperationException {
+ throw new UnsupportedOperationException("Not implemented.");
+ }
+
/**
* Obtains a child file.
* @param name a relative path, possibly including {@code /} (but not {@code ..})
@@ -312,6 +388,18 @@ public int mode() throws IOException {
*/
public abstract InputStream open() throws IOException;
+ /**
+ * Opens an input stream on the file so its contents can be read.
+ *
+ * @param noFollowLinks if true do not follow links.
+ * @return an open stream
+ * @throws IOException if it could not be opened
+ */
+ @Restricted(NoExternalUse.class)
+ public InputStream open(boolean noFollowLinks) throws IOException {
+ return open();
+ }
+
/**
* Does case-insensitive comparison.
* {@inheritDoc}
@@ -470,6 +558,20 @@ private static final class FileVF extends VirtualFile {
return vfs;
}
+ @NonNull
+ @Override
+ public VirtualFile[] list(boolean noFollowLinks) throws IOException {
+ String rootPath = determineRootPath();
+ File[] kids = f.listFiles();
+ List<VirtualFile> contents = new ArrayList<>(kids.length);
+ for (File child : kids) {
+ if (!FilePath.isSymlink(child, rootPath, noFollowLinks)) {
+ contents.add(new FileVF(child, root));
+ }
+ }
+ return contents.toArray(new VirtualFile[0]);
+ }
+
@Override public boolean supportsQuickRecursiveListing() {
return true;
}
@@ -499,6 +601,32 @@ public Collection<String> list(String includes, String excludes, boolean useDefa
}
return new Scanner(includes, excludes, useDefaultExcludes).invoke(f, null);
}
+
+ @Override
+ public Collection<String> list(String includes, String excludes, boolean useDefaultExcludes,
+ boolean noFollowLinks) throws IOException {
+ String rootPath = determineRootPath();
+ return new Scanner(includes, excludes, useDefaultExcludes, rootPath, noFollowLinks).invoke(f, null);
+ }
+
+ @Override
+ public int zip(OutputStream outputStream, String includes, String excludes, boolean useDefaultExcludes,
+ boolean noFollowLinks) throws IOException {
+ String rootPath = determineRootPath();
+ DirScanner.Glob globScanner = new DirScanner.Glob(includes, excludes, useDefaultExcludes, !noFollowLinks);
+ ArchiverFactory archiverFactory = noFollowLinks ? ArchiverFactory.ZIP_WTHOUT_FOLLOWING_SYMLINKS : ArchiverFactory.ZIP;
+ try (Archiver archiver = archiverFactory.create(outputStream)) {
+ globScanner.scan(f, FilePath.ignoringSymlinks(archiver, rootPath, noFollowLinks));
+ return archiver.countEntries();
+ }
+ }
+
+ @Override
+ public boolean hasSymlink(boolean noFollowLinks) throws IOException {
+ String rootPath = determineRootPath();
+ return FilePath.isSymlink(f, rootPath, noFollowLinks);
+ }
+
@Override public VirtualFile child(String name) {
return new FileVF(new File(f, name), root);
}
@@ -536,6 +664,28 @@ public Collection<String> list(String includes, String excludes, boolean useDefa
throw new IOException(e);
}
}
+ @Override
+ public InputStream open(boolean noFollowLinks) throws IOException {
+ String rootPath = determineRootPath();
+ InputStream inputStream = FilePath.newInputStreamDenyingSymlinkAsNeeded(f, rootPath, noFollowLinks);
+ return inputStream;
+ }
+
+ @Override
+ public boolean containsSymLinkChild(boolean noFollowLinks) {
+ String rootPath = determineRootPath();
+ File[] kids = f.listFiles();
+ for (File child : kids) {
+ if (FilePath.isSymlink(child, rootPath, noFollowLinks)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private String determineRootPath() {
+ return root == null ? null : root.getPath();
+ }
private boolean isIllegalSymlink() {
try {
@@ -680,11 +830,44 @@ private static final class FilePathVF extends VirtualFile {
@Override public VirtualFile[] list() throws IOException {
try {
List<FilePath> kids = f.list();
- VirtualFile[] vfs = new VirtualFile[kids.size()];
- for (int i = 0; i < vfs.length; i++) {
- vfs[i] = new FilePathVF(kids.get(i), this.root);
- }
- return vfs;
+ return convertChildrenToVirtualFile(kids);
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
+ }
+
+ private VirtualFile[] convertChildrenToVirtualFile(List<FilePath> kids) {
+ VirtualFile[] vfs = new VirtualFile[kids.size()];
+ for (int i = 0; i < vfs.length; i++) {
+ vfs[i] = new FilePathVF(kids.get(i), this.root);
+ }
+ return vfs;
+ }
+
+ @NonNull
+ @Override
+ public VirtualFile[] list(boolean noFollowLinks) throws IOException {
+ try {
+ List<FilePath> kids = f.list(root, noFollowLinks);
+ return convertChildrenToVirtualFile(kids);
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
+ }
+
+ @Override
+ public boolean containsSymLinkChild(boolean noFollowLinks) throws IOException {
+ try {
+ return f.containsSymlink(root, noFollowLinks);
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
+ }
+
+ @Override
+ public boolean hasSymlink(boolean noFollowLinks) throws IOException {
+ try {
+ return f.hasSymlink(root, noFollowLinks);
} catch (InterruptedException x) {
throw new IOException(x);
}
@@ -716,13 +899,37 @@ private static final class FilePathVF extends VirtualFile {
}
}
- @Override public Collection<String> list(String includes, String excludes, boolean useDefaultExcludes) throws IOException {
- try {
- return f.act(new Scanner(includes, excludes, useDefaultExcludes));
- } catch (InterruptedException x) {
- throw new IOException(x);
+ @Override public Collection<String> list(String includes, String excludes, boolean useDefaultExcludes) throws IOException {
+ try {
+ return f.act(new Scanner(includes, excludes, useDefaultExcludes));
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
}
- }
+
+ @Override
+ public Collection<String> list(String includes, String excludes, boolean useDefaultExcludes,
+ boolean noFollowLinks) throws IOException {
+ try {
+ String rootPath = root == null ? null : root.getRemote();
+ return f.act(new Scanner(includes, excludes, useDefaultExcludes, rootPath, noFollowLinks));
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
+ }
+
+ @Override
+ public int zip(OutputStream outputStream, String includes, String excludes, boolean useDefaultExcludes,
+ boolean noFollowLinks) throws IOException {
+ try {
+ String rootPath = root == null ? null : root.getRemote();
+ DirScanner.Glob globScanner = new DirScanner.Glob(includes, excludes, useDefaultExcludes, !noFollowLinks);
+ return f.zip(outputStream, globScanner, rootPath, noFollowLinks);
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
+ }
+
@Override public VirtualFile child(String name) {
return new FilePathVF(f.child(name), this.root);
}
@@ -761,6 +968,13 @@ private static final class FilePathVF extends VirtualFile {
throw new IOException(x);
}
}
+ @Override public InputStream open(boolean noFollowLinks) throws IOException {
+ try {
+ return f.read(root == null ? null : root, noFollowLinks);
+ } catch (InterruptedException x) {
+ throw new IOException(x);
+ }
+ }
@Override public <V> V run(Callable<V,IOException> callable) throws IOException {
try {
return f.act(callable);
@@ -841,22 +1055,34 @@ private String computeRelativePathToRoot(){
private static final class Scanner extends MasterToSlaveFileCallable<List<String>> {
private final String includes, excludes;
private final boolean useDefaultExcludes;
- Scanner(String includes, String excludes, boolean useDefaultExcludes) {
+ private final String verificationRoot;
+ private final boolean noFollowLinks;
+
+ Scanner(String includes, String excludes, boolean useDefaultExcludes, String verificationRoot, boolean noFollowLinks) {
this.includes = includes;
this.excludes = excludes;
this.useDefaultExcludes = useDefaultExcludes;
+ this.verificationRoot = verificationRoot;
+ this.noFollowLinks = noFollowLinks;
+ }
+
+ public Scanner(String includes, String excludes, boolean useDefaultExcludes) {
+ this(includes, excludes, useDefaultExcludes, null, false);
}
+
@Override public List<String> invoke(File f, VirtualChannel channel) throws IOException {
if (includes.isEmpty()) { // see Glob class Javadoc, and list(String, String, boolean) note
return Collections.emptyList();
}
final List<String> paths = new ArrayList<>();
- new DirScanner.Glob(includes, excludes, useDefaultExcludes).scan(f, new FileVisitor() {
+ FileVisitor listing = new FileVisitor() {
@Override
- public void visit(File f, String relativePath) throws IOException {
+ public void visit(File f, String relativePath) {
paths.add(relativePath.replace('\\', '/'));
}
- });
+ };
+ DirScanner.Glob globScanner = new DirScanner.Glob(includes, excludes, useDefaultExcludes, !noFollowLinks);
+ globScanner.scan(f, FilePath.ignoringSymlinks(listing, verificationRoot, noFollowLinks));
return paths;
}
core/src/main/resources/hudson/model/DirectoryBrowserSupport/dir.jelly+13 1
@@ -45,6 +45,12 @@ THE SOFTWARE.
<j:choose>
<j:when test="${empty(files)}">
${%No files in directory}
+ <j:if test="${showSymlinkWarning}">
+ <p>
+ <img id="symlinkalert" src="${imagesURL}/16x16/warning.png"/>
+ ${%Symlinks are hidden}
+ </p>
+ </j:if>
</j:when>
<j:otherwise>
<table class="fileList">
@@ -105,7 +111,13 @@ THE SOFTWARE.
</div>
</td>
</tr>
- </table>
+ </table>
+ <j:if test="${showSymlinkWarning}">
+ <p>
+ <img id="alert" src="${imagesURL}/16x16/warning.png"/>
+ ${%Symlinks are hidden}
+ </p>
+ </j:if>
</j:otherwise>
</j:choose>
</div>
core/src/test/java/jenkins/util/VirtualFileTest.java+375 0
@@ -39,13 +39,22 @@
import org.junit.rules.TemporaryFolder;
import org.jvnet.hudson.test.Issue;
+import javax.annotation.Nonnull;
import java.io.File;
import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
+import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.attribute.PosixFilePermissions;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collection;
+import java.util.Date;
+import java.util.GregorianCalendar;
+import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
@@ -186,6 +195,105 @@ public InputStream open() throws IOException {
}
}
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_IllegalSymlink_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ File a = new File(root, "a");
+ VirtualFile virtualRoot = VirtualFile.forFile(a);
+ VirtualFile virtualChild = virtualRoot.child("_b");
+ Collection<String> children = virtualChild.list("**", null, true);
+ assertThat(children, empty());
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_Glob_NoFollowLinks_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ Collection<String> children = virtualRoot.list("**", null, true, true);
+ assertThat(children, containsInAnyOrder(
+ "a/aa/aa.txt",
+ "a/ab/ab.txt",
+ "b/ba/ba.txt"
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_Glob_NoFollowLinks_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ Collection<String> children = virtualRoot.list("**", null, true, true);
+ assertThat(children, containsInAnyOrder(
+ "a/aa/aa.txt",
+ "a/ab/ab.txt",
+ "b/ba/ba.txt"
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void zip_NoFollowLinks_FilePathVF() throws Exception {
+ File zipFile = new File(tmp.getRoot(), "output.zip");
+ File root = tmp.getRoot();
+ File source = new File(root, "source");
+ prepareFileStructureForIsDescendant(source);
+
+ VirtualFile sourcePath = VirtualFile.forFilePath(new FilePath(source));
+ try (FileOutputStream outputStream = new FileOutputStream(zipFile)) {
+ sourcePath.zip( outputStream,"**", null, true, true);
+ }
+ FilePath zipPath = new FilePath(zipFile);
+ assertTrue(zipPath.exists());
+ assertFalse(zipPath.isDirectory());
+ FilePath unzipPath = new FilePath(new File(tmp.getRoot(), "unzip"));
+ zipPath.unzip(unzipPath);
+ assertTrue(unzipPath.exists());
+ assertTrue(unzipPath.isDirectory());
+ assertTrue(unzipPath.child("a").child("aa").child("aa.txt").exists());
+ assertTrue(unzipPath.child("a").child("ab").child("ab.txt").exists());
+ assertFalse(unzipPath.child("a").child("aa").child("aaa").exists());
+ assertFalse(unzipPath.child("a").child("_b").exists());
+ assertTrue(unzipPath.child("b").child("ba").child("ba.txt").exists());
+ assertFalse(unzipPath.child("b").child("_a").exists());
+ assertFalse(unzipPath.child("b").child("_aatxt").exists());
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void zip_NoFollowLinks_FileVF() throws Exception {
+ File zipFile = new File(tmp.getRoot(), "output.zip");
+ File root = tmp.getRoot();
+ File source = new File(root, "source");
+ prepareFileStructureForIsDescendant(source);
+
+ VirtualFile sourcePath = VirtualFile.forFile(source);
+ try (FileOutputStream outputStream = new FileOutputStream(zipFile)) {
+ sourcePath.zip( outputStream,"**", null, true, true);
+ }
+ FilePath zipPath = new FilePath(zipFile);
+ assertTrue(zipPath.exists());
+ assertFalse(zipPath.isDirectory());
+ FilePath unzipPath = new FilePath(new File(tmp.getRoot(), "unzip"));
+ zipPath.unzip(unzipPath);
+ assertTrue(unzipPath.exists());
+ assertTrue(unzipPath.isDirectory());
+ assertTrue(unzipPath.child("a").child("aa").child("aa.txt").exists());
+ assertTrue(unzipPath.child("a").child("ab").child("ab.txt").exists());
+ assertFalse(unzipPath.child("a").child("aa").child("aaa").exists());
+ assertFalse(unzipPath.child("a").child("_b").exists());
+ assertTrue(unzipPath.child("b").child("ba").child("ba.txt").exists());
+ assertFalse(unzipPath.child("b").child("_a").exists());
+ assertFalse(unzipPath.child("b").child("_aatxt").exists());
+ }
+
@Issue("JENKINS-26810")
@Test public void readLink() throws Exception {
assumeFalse("Symlinks do not work well on Windows", Functions.isWindows());
@@ -204,6 +312,344 @@ public InputStream open() throws IOException {
}
}
+ @Test
+ public void simpleList_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list());
+ assertThat(children, hasSize(2));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("a"),
+ VFMatcher.hasName("b")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list(true));
+ assertThat(children, hasSize(2));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("a"),
+ VFMatcher.hasName("b")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list(true));
+ assertThat(children, hasSize(2));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("a"),
+ VFMatcher.hasName("b")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void simpleList_WithSymlink_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ VirtualFile virtualRootChildA = virtualRoot.child("a");
+ List<VirtualFile> children = Arrays.asList(virtualRootChildA.list());
+ assertThat(children, hasSize(3));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("aa"),
+ VFMatcher.hasName("ab"),
+ VFMatcher.hasName("_b")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_ExternalSymlink_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+ File root = tmp.getRoot();
+ String symlinkName = "symlink";
+ Util.createSymlink(root, "a", symlinkName, null);
+ File symlinkFile = new File(root, symlinkName);
+ VirtualFile virtualRootSymlink = VirtualFile.forFile(symlinkFile);
+ List<VirtualFile> children = Arrays.asList(virtualRootSymlink.list(true));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("aa"),
+ VFMatcher.hasName("ab")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_ExternalSymlink_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+ File root = tmp.getRoot();
+ String symlinkName = "symlink";
+ Util.createSymlink(root, "a", symlinkName, null);
+ File symlinkFile = new File(root, symlinkName);
+ VirtualFile virtualRootSymlink = VirtualFile.forFilePath(new FilePath(symlinkFile));
+ List<VirtualFile> children = Arrays.asList(virtualRootSymlink.list(true));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("aa"),
+ VFMatcher.hasName("ab")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_Glob_NoFollowLinks_ExternalSymlink_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+ File root = tmp.getRoot();
+ String symlinkName = "symlink";
+ Util.createSymlink(root, "a", symlinkName, null);
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ File symlinkFile = new File(root, symlinkName);
+ FilePath symlinkPath = new FilePath(symlinkFile);
+ VirtualFile symlinkVirtualPath = VirtualFile.forFilePath(symlinkPath);
+ VirtualFile symlinkChildVirtualPath = symlinkVirtualPath.child("aa");
+ Collection<String> children = symlinkChildVirtualPath.list("**", null, true, true);
+ assertThat(children, contains("aa.txt"));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_Glob_NoFollowLinks_ExternalSymlink_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+ File root = tmp.getRoot();
+ String symlinkName = "symlink";
+ Util.createSymlink(root, "a", symlinkName, null);
+ File symlinkFile = new File(root, symlinkName);
+ VirtualFile symlinkVirtualFile = VirtualFile.forFile(symlinkFile);
+ VirtualFile symlinkChildVirtualFile = symlinkVirtualFile.child("aa");
+ Collection<String> children = symlinkChildVirtualFile.list("**", null, true, true);
+ assertThat(children, contains("aa.txt"));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_InternalSymlink_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile rootVirtualFile = VirtualFile.forFile(root);
+ VirtualFile virtualRootChildA = rootVirtualFile.child("a");
+ List<VirtualFile> children = Arrays.asList(virtualRootChildA.list(true));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("aa"),
+ VFMatcher.hasName("ab")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_InternalSymlink_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ FilePath rootPath = new FilePath(root);
+ VirtualFile rootVirtualPath = VirtualFile.forFilePath(rootPath);
+ VirtualFile virtualRootChildA = rootVirtualPath.child("a");
+ List<VirtualFile> children = Arrays.asList(virtualRootChildA.list(true));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("aa"),
+ VFMatcher.hasName("ab")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_NoKids_FileVF() throws Exception {
+ File root = tmp.getRoot();
+ FileUtils.touch(root);
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list());
+ assertThat(children, empty());
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_Glob_NoFollowLinks_NoKids_FileVF() throws Exception {
+ File root = tmp.getRoot();
+ FileUtils.touch(root);
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ Collection<String> children = virtualRoot.list("**", null, true, true);
+ assertThat(children, empty());
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_Glob_NoFollowLinks_NoKids_FilePathVF() throws Exception {
+ File root = tmp.getRoot();
+ FileUtils.touch(root);
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ Collection<String> children = virtualRoot.list("**", null, true, true);
+ assertThat(children, empty());
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void list_NoFollowLinks_NoKids_FilePathVF() throws Exception {
+ File root = tmp.getRoot();
+ FileUtils.touch(root);
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list(false));
+ assertThat(children, empty());
+ }
+
+ @Test
+ public void simpleList_NoKids_FileVF() throws Exception {
+ File root = tmp.getRoot();
+ FileUtils.touch(root);
+ VirtualFile virtualRoot = VirtualFile.forFile(root);
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list());
+ assertThat(children, empty());
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void simpleList_IllegalSymlink_FileVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ File a = new File(root, "a");
+ VirtualFile virtualRoot = VirtualFile.forFile(a);
+ VirtualFile virtualChild = virtualRoot.child("_b");
+ List<VirtualFile> children = Arrays.asList(virtualChild.list());
+ assertThat(children, empty());
+ }
+
+ @Test
+ public void simpleList_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list());
+ assertThat(children, hasSize(2));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("a"),
+ VFMatcher.hasName("b")
+ ));
+ }
+
+ @Test
+ @Issue("SECURITY-1452")
+ public void simpleList_WithSymlink_FilePathVF() throws Exception {
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ VirtualFile virtualRootChildA = virtualRoot.child("a");
+ List<VirtualFile> children = Arrays.asList(virtualRootChildA.list());
+ assertThat(children, hasSize(3));
+ assertThat(children, containsInAnyOrder(
+ VFMatcher.hasName("aa"),
+ VFMatcher.hasName("ab"),
+ VFMatcher.hasName("_b")
+ ));
+ }
+
+ @Test
+ public void simpleList_NoKids_FilePathVF() throws Exception {
+ File root = tmp.getRoot();
+ FileUtils.touch(root);
+ VirtualFile virtualRoot = VirtualFile.forFilePath(new FilePath(root));
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list());
+ assertThat(children, empty());
+ }
+
+ @Test
+ public void simpleList_AbstractBase() throws Exception {
+ // This test checks the method's behavior in the abstract base class,
+ // which has limited behavior.
+ prepareFileStructureForIsDescendant(tmp.getRoot());
+
+ File root = tmp.getRoot();
+ VirtualFile virtualRoot = new VirtualFileMinimalImplementation(root);
+ List<VirtualFile> children = Arrays.asList(virtualRoot.list());
+ assertThat(children, hasSize(2));
+ assertThat(children, containsInAnyOrder(
… diff truncated
More files changed — see the full commit.

References