VYPR
Critical severityNVD Advisory· Published Nov 4, 2021· Updated Aug 3, 2024

CVE-2021-21694

CVE-2021-21694

Description

Jenkins core 2.318 and earlier, LTS 2.303.2 and earlier, fails to enforce agent-to-controller path filtering in FilePath#toURI and four other methods, allowing arbitrary file system access.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Jenkins core 2.318 and earlier, LTS 2.303.2 and earlier, fails to enforce agent-to-controller path filtering in FilePath#toURI and four other methods, allowing arbitrary file system access.

Vulnerability

Jenkins core 2.318 and earlier, and LTS 2.303.2 and earlier, contains a missing permission check vulnerability in the FilePath#toURI, FilePath#hasSymlink, FilePath#absolutize, FilePath#isDescendant, and FilePath#get*DiskSpace methods. These methods are part of the agent-to-controller security subsystem, which is intended to limit which files on the controller can be accessed by agent processes. The affected code paths do not call any access control checks, allowing operations that should be restricted. This issue is tracked as SECURITY-2455 and is part of a broader set of path filtering bypass vulnerabilities [1] [2].

Exploitation

An attacker with agent process control (e.g., through a malicious Jenkins agent or a compromised job that executes on an agent) can invoke these FilePath methods without needing any additional permissions. The attacker does not require authentication beyond what is needed to run a job on an agent, and no user interaction is required. By calling the unguarded methods, the attacker can perform operations that would normally be blocked by the path filter, such as reading arbitrary files or manipulating paths to traverse outside allowed directories [1].

Impact

Successful exploitation allows an agent process to read and write arbitrary files on the Jenkins controller file system, and obtain information about the controller's file system layout. This can lead to full compromise of the Jenkins controller, including disclosure of secrets, configuration files, and credentials, as well as potential remote code execution through writing malicious files (e.g., plugin modifications, job configurations). The CVSS base severity is Critical (10.0) due to the high impact on confidentiality, integrity, and availability [1].

Mitigation

Jenkins has released fixed versions: Jenkins 2.319 and LTS 2.303.3 contain the patch for this vulnerability. The fix adds permission checks to the affected FilePath methods. Users should upgrade immediately. No workaround is available if upgrade is not possible; however, restricting agent process execution to trusted jobs and minimizing the number of agents can reduce risk. These CVEs are not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog as of the advisory date. The commit introducing the fix can be seen in the Jenkins repository [3] [4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
org.jenkins-ci.main:jenkins-coreMaven
< 2.303.32.303.3
org.jenkins-ci.main:jenkins-coreMaven
>= 2.304, < 2.3192.319

Affected products

3

Patches

3
104c751d9079

[SECURITY-2455]

https://github.com/jenkinsci/jenkinsDaniel BeckNov 2, 2021via ghsa
3 files changed · +17 19
  • core/src/main/java/hudson/FilePath.java+0 19 modified
    @@ -215,11 +215,6 @@ public final class FilePath implements SerializableOnlyOverRemoting {
          */
         private static final int MAX_REDIRECTS = 20;
     
    -    /**
    -     * Escape hatch for some additional protections against sending callables intended to be locally used only
    -     */
    -    private static /* non-final for Groovy */ boolean REJECT_LOCAL_CALLABLE_DESERIALIZATION = SystemProperties.getBoolean(FilePath.class.getName() + ".rejectLocalCallableDeserialization", true);
    -
         /**
          * When this {@link FilePath} represents the remote path,
          * this field is always non-null on the controller (the field represents
    @@ -601,13 +596,6 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException, Interru
                 return null;
             }
             private static final long serialVersionUID = 1L;
    -
    -        protected Object readResolve() {
    -            if (REJECT_LOCAL_CALLABLE_DESERIALIZATION) {
    -                throw new IllegalStateException("This callable is not intended to be sent through a channel");
    -            }
    -            return this;
    -        }
         }
     
         /**
    @@ -660,13 +648,6 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException, Interru
                 return null;
             }
             private static final long serialVersionUID = 1L;
    -
    -        protected Object readResolve() {
    -            if (REJECT_LOCAL_CALLABLE_DESERIALIZATION) {
    -                throw new IllegalStateException("This callable is not intended to be sent through a channel");
    -            }
    -            return this;
    -        }
         }
     
         /**
    
  • test/src/test/java/jenkins/security/Security2455Test.java+17 0 modified
    @@ -21,6 +21,7 @@
     import hudson.model.Node;
     import hudson.model.TaskListener;
     import hudson.remoting.VirtualChannel;
    +import hudson.slaves.DumbSlave;
     import java.io.File;
     import java.io.FileReader;
     import java.io.IOException;
    @@ -809,6 +810,22 @@ public Object call() throws Exception {
     
         // --------
     
    +    // Misc tests
    +
    +    @LocalData
    +    @Test
    +    public void testRemoteLocalUnzip() throws Exception {
    +        final DumbSlave onlineSlave = j.createOnlineSlave();
    +        final File zipFile = new File(j.jenkins.getRootDir(), "file.zip");
    +        assertTrue(zipFile.isFile());
    +        final FilePath agentRootPath = onlineSlave.getRootPath();
    +        final FilePath agentZipPath = agentRootPath.child("file.zip");
    +        new FilePath(zipFile).copyTo(agentZipPath);
    +        agentZipPath.unzip(agentRootPath);
    +    }
    +
    +    // --------
    +
         // Utility functions
     
         protected static FilePath toFilePathOnController(File file) {
    
  • test/src/test/resources/jenkins/security/Security2455Test/testRemoteLocalUnzip/file.zip+0 0 added
5a245e42979a

[SECURITY-2455]

https://github.com/jenkinsci/jenkinsJesse GlickOct 25, 2021via ghsa
3 files changed · +104 4
  • core/src/main/java/jenkins/security/s2m/FilePathRuleConfig.java+3 1 modified
    @@ -48,7 +48,9 @@ protected FilePathRule parse(String line) {
             if (line.isEmpty())     return null;
     
             // TODO This does not support custom build dir configuration (Jenkins#getRawBuildsDir() etc.)
    -        line = line.replace("<BUILDDIR>","<JOBDIR>/builds/<BUILDID>");
    +        line = line.replace("<BUILDDIR>","<JOBDIR>/builds/[0-9]+");
    +
    +        // Kept only for compatibility with custom user-provided rules:
             line = line.replace("<BUILDID>","(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9]-[0-9][0-9]-[0-9][0-9]|[0-9]+)");
             line = line.replace("<JOBDIR>","<JENKINS_HOME>/jobs/.+");
             final File jenkinsHome = Jenkins.get().getRootDir();
    
  • core/src/main/resources/jenkins/security/s2m/filepath-filter.conf+6 2 modified
    @@ -38,8 +38,12 @@ deny all <BUILDDIR>/checkpoints($|/.*)
     # But not allowing deletion to prevent data loss and symlink to prevent jailbreaking.
     allow create,mkdirs,read,stat,write <BUILDDIR>/.+
     
    -# cobertura also writes out annotated sources to a dir under the job:
    -allow create,mkdirs,read,stat,write <JENKINS_HOME>/jobs/.+/cobertura.*
    +# cobertura also writes out annotated sources to a dir under the Maven module:
    +allow create,mkdirs,read,stat,write <JOBDIR>/modules/([^/]+)/cobertura($|/.*)
    +
    +# Some maven-plugin reporters also create content outside of build directories (including one in cobertura but that is covered above):
    +allow create,mkdirs,read,stat,write <JENKINS_HOME>(/jobs/([^/]+))+(|/modules/([^/]+))/(javadoc|test-javadoc)($|/.*)
    +allow create,mkdirs,read,stat,write <JENKINS_HOME>(/jobs/([^/]+))+/site($|/.*)
     
     # all the other accesses that aren't specified here will be left up to other rules in this directory.
     # if no rules in those other files matches, then the access will be rejected.
    
  • test/src/test/java/jenkins/security/Security2455Test.java+95 1 modified
    @@ -17,6 +17,7 @@
     import hudson.Util;
     import hudson.model.Cause;
     import hudson.model.FreeStyleBuild;
    +import hudson.model.FreeStyleProject;
     import hudson.model.Node;
     import hudson.model.TaskListener;
     import hudson.remoting.VirtualChannel;
    @@ -44,6 +45,7 @@
     import org.jvnet.hudson.test.Issue;
     import org.jvnet.hudson.test.JenkinsRule;
     import org.jvnet.hudson.test.LoggerRule;
    +import org.jvnet.hudson.test.MockFolder;
     import org.jvnet.hudson.test.recipes.LocalData;
     
     @SuppressWarnings("ThrowableNotThrown")
    @@ -719,6 +721,94 @@ public Integer call() throws Exception {
     
         // --------
     
    +    @Issue("SECURITY-2455") // general issue -- Maven Projects would no longer be allowed to perform some actions
    +    @Test
    +    public void testMavenReportersAllowListForTopLevelJob() throws Exception {
    +        final FreeStyleProject project = j.createFreeStyleProject();
    +        final File topLevelProjectDir = project.getRootDir();
    +
    +        // similar but wrong names:
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "not-site"))));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "not-javadoc"))));
    +
    +        // project-level archived stuff:
    +        invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "javadoc")));
    +        invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "test-javadoc")));
    +        invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "site")));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "cobertura"))));
    +
    +        // cannot mkdirs this from agent:
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(topLevelProjectDir, "modules"))));
    +
    +        final File mavenModuleDir = new File(topLevelProjectDir, "modules/pretend-maven-module");
    +        assertTrue(mavenModuleDir.mkdirs());
    +
    +        // module-level archived stuff:
    +        invokeOnAgent(new MkDirsWriter(new File(mavenModuleDir, "javadoc")));
    +        invokeOnAgent(new MkDirsWriter(new File(mavenModuleDir, "test-javadoc")));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(mavenModuleDir, "site"))));
    +        invokeOnAgent(new MkDirsWriter(new File(mavenModuleDir, "cobertura")));
    +    }
    +
    +    @Issue("SECURITY-2455") // general issue -- Maven Projects would no longer be allowed to perform some actions
    +    @Test
    +    public void testMavenReportersAllowListForJobInFolder() throws Exception {
    +        final MockFolder theFolder = j.createFolder("theFolder");
    +        {
    +            // basic child job
    +            final FreeStyleProject childProject = theFolder.createProject(FreeStyleProject.class, "child");
    +            final File childProjectRootDir = childProject.getRootDir();
    +
    +            // project-level archived stuff for child project inside folder:
    +            invokeOnAgent(new MkDirsWriter(new File(childProjectRootDir, "javadoc")));
    +            invokeOnAgent(new MkDirsWriter(new File(childProjectRootDir, "test-javadoc")));
    +            invokeOnAgent(new MkDirsWriter(new File(childProjectRootDir, "site")));
    +            assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(childProjectRootDir, "cobertura"))));
    +        }
    +
    +        { // misleadingly named child job (like one of the approved folders):
    +            final FreeStyleProject siteChildProject = theFolder.createProject(FreeStyleProject.class, "site");
    +            final File siteChildProjectRootDir = siteChildProject.getRootDir();
    +
    +            // cannot mkdirs this from agent despite 'site' in the path (but on wrong level):
    +            assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(siteChildProjectRootDir)));
    +            assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(siteChildProjectRootDir, "foo"))));
    +            assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(siteChildProjectRootDir, "modules"))));
    +
    +            // project-level archived stuff for another child inside folder:
    +            invokeOnAgent(new MkDirsWriter(new File(siteChildProjectRootDir, "javadoc")));
    +            invokeOnAgent(new MkDirsWriter(new File(siteChildProjectRootDir, "test-javadoc")));
    +            invokeOnAgent(new MkDirsWriter(new File(siteChildProjectRootDir, "site")));
    +            assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(siteChildProjectRootDir, "cobertura"))));
    +
    +            final File childProjectMavenModuleDir = new File(siteChildProjectRootDir, "modules/pretend-maven-module");
    +            assertTrue(childProjectMavenModuleDir.mkdirs());
    +
    +            // module-level archived stuff:
    +            invokeOnAgent(new MkDirsWriter(new File(childProjectMavenModuleDir, "javadoc")));
    +            invokeOnAgent(new MkDirsWriter(new File(childProjectMavenModuleDir, "test-javadoc")));
    +            assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkDirsWriter(new File(childProjectMavenModuleDir, "site"))));
    +            invokeOnAgent(new MkDirsWriter(new File(childProjectMavenModuleDir, "cobertura")));
    +        }
    +    }
    +
    +    private static class MkDirsWriter extends MasterToSlaveCallable<Object, Exception> {
    +        private final File root;
    +
    +        private MkDirsWriter(File root) {
    +            this.root = root;
    +        }
    +
    +        @Override
    +        public Object call() throws Exception {
    +            toFilePathOnController(root).mkdirs();
    +            toFilePathOnController(new File(root, "file.txt")).write("text", "UTF-8");
    +            return null;
    +        }
    +    }
    +
    +    // --------
    +
         // Utility functions
     
         protected static FilePath toFilePathOnController(File file) {
    @@ -730,8 +820,12 @@ protected static FilePath toFilePathOnController(String path) {
             return new FilePath(channel, path);
         }
     
    +    protected Node agent;
    +
         protected  <T, X extends Throwable> T invokeOnAgent(MasterToSlaveCallable<T, X> callable) throws Exception, X {
    -        final Node agent = j.createOnlineSlave();
    +        if (agent == null) {
    +            agent = j.createOnlineSlave();
    +        }
             return Objects.requireNonNull(agent.getChannel()).call(callable);
         }
     
    
63cde2daadc7

[SECURITY-2455]

https://github.com/jenkinsci/jenkinsDaniel BeckOct 25, 2021via ghsa
7 files changed · +991 132
  • core/src/main/java/hudson/FilePath.java+168 123 modified
    @@ -34,7 +34,6 @@
     import com.jcraft.jzlib.GZIPOutputStream;
     import edu.umd.cs.findbugs.annotations.CheckForNull;
     import edu.umd.cs.findbugs.annotations.NonNull;
    -import edu.umd.cs.findbugs.annotations.Nullable;
     import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
     import hudson.Launcher.LocalLauncher;
     import hudson.Launcher.RemoteLauncher;
    @@ -216,6 +215,11 @@ public final class FilePath implements SerializableOnlyOverRemoting {
          */
         private static final int MAX_REDIRECTS = 20;
     
    +    /**
    +     * Escape hatch for some additional protections against sending callables intended to be locally used only
    +     */
    +    private static /* non-final for Groovy */ boolean REJECT_LOCAL_CALLABLE_DESERIALIZATION = SystemProperties.getBoolean(FilePath.class.getName() + ".rejectLocalCallableDeserialization", true);
    +
         /**
          * When this {@link FilePath} represents the remote path,
          * this field is always non-null on the controller (the field represents
    @@ -239,18 +243,6 @@ public final class FilePath implements SerializableOnlyOverRemoting {
          */
         private /*final*/ String remote;
     
    -    /**
    -     * If this {@link FilePath} is deserialized to handle file access request from a remote computer,
    -     * this field is set to the filter that performs access control.
    -     *
    -     * <p>
    -     * If null, no access control is needed.
    -     *
    -     * @see #filterNonNull()
    -     */
    -    private transient @Nullable
    -    SoloFilePathFilter filter;
    -
         /**
          * Creates a {@link FilePath} that represents a path on the given node.
          *
    @@ -527,7 +519,7 @@ public int archive(final ArchiverFactory factory, OutputStream os, final DirScan
             final OutputStream out = channel != null ? new RemoteOutputStream(os) : os;
             return act(new Archive(factory, out, scanner, verificationRoot, noFollowLinks));
         }
    -    private class Archive extends SecureFileCallable<Integer> {
    +    private static class Archive extends SecureFileCallable<Integer> {
             private final ArchiverFactory factory;
             private final OutputStream out;
             private final DirScanner scanner;
    @@ -573,14 +565,14 @@ public int archive(final ArchiverFactory factory, OutputStream os, final String
          */
         public void unzip(final FilePath target) throws IOException, InterruptedException {
             // TODO: post release, re-unite two branches by introducing FileStreamCallable that resolves InputStream
    -        if (this.channel!=target.channel) {// local -> remote or remote->local
    +        if (channel != target.channel) {// local -> remote or remote->local
                 final RemoteInputStream in = new RemoteInputStream(read(), Flag.GREEDY);
                 target.act(new UnzipRemote(in));
             } else {// local -> local or remote->remote
    -            target.act(new UnzipLocal());
    +            target.act(new UnzipLocal(this));
             }
         }
    -    private class UnzipRemote extends SecureFileCallable<Void> {
    +    private static class UnzipRemote extends SecureFileCallable<Void> {
             private final RemoteInputStream in;
             UnzipRemote(RemoteInputStream in) {
                 this.in = in;
    @@ -592,14 +584,30 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException, Interru
             }
             private static final long serialVersionUID = 1L;
         }
    -    private class UnzipLocal extends SecureFileCallable<Void> {
    +    private static class UnzipLocal extends SecureFileCallable<Void> {
    +
    +        private final FilePath filePath;
    +
    +        private UnzipLocal(FilePath filePath) {
    +            this.filePath = filePath;
    +        }
    +
             @Override
             public Void invoke(File dir, VirtualChannel channel) throws IOException, InterruptedException {
    -            assert !FilePath.this.isRemote();       // this.channel==target.channel above
    -            unzip(dir, reading(new File(FilePath.this.getRemote()))); // shortcut to local file
    +            if (this.filePath.isRemote()) {
    +                throw new IllegalStateException("Expected local path for file: " + filePath); // this.channel==target.channel above
    +            }
    +            unzip(dir, reading(new File(this.filePath.getRemote()))); // shortcut to local file
                 return null;
             }
             private static final long serialVersionUID = 1L;
    +
    +        protected Object readResolve() {
    +            if (REJECT_LOCAL_CALLABLE_DESERIALIZATION) {
    +                throw new IllegalStateException("This callable is not intended to be sent through a channel");
    +            }
    +            return this;
    +        }
         }
     
         /**
    @@ -613,39 +621,52 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException, Interru
          * @see #untarFrom(InputStream, TarCompression)
          */
         public void untar(final FilePath target, final TarCompression compression) throws IOException, InterruptedException {
    +        final FilePath source = FilePath.this;
             // TODO: post release, re-unite two branches by introducing FileStreamCallable that resolves InputStream
    -        if (this.channel!=target.channel) {// local -> remote or remote->local
    -            final RemoteInputStream in = new RemoteInputStream(read(), Flag.GREEDY);
    -            target.act(new UntarRemote(compression, in));
    +        if (source.channel != target.channel) {// local -> remote or remote->local
    +            final RemoteInputStream in = new RemoteInputStream(source.read(), Flag.GREEDY);
    +            target.act(new UntarRemote(source.getName(), compression, in));
             } else {// local -> local or remote->remote
    -            target.act(new UntarLocal(compression));
    +            target.act(new UntarLocal(source, compression));
             }
         }
    -    private class UntarRemote extends SecureFileCallable<Void> {
    +    private static class UntarRemote extends SecureFileCallable<Void> {
             private final TarCompression compression;
             private final RemoteInputStream in;
    -        UntarRemote(TarCompression compression, RemoteInputStream in) {
    +        private final String name;
    +        UntarRemote(String name, TarCompression compression, RemoteInputStream in) {
                 this.compression = compression;
                 this.in = in;
    +            this.name = name;
             }
             @Override
             public Void invoke(File dir, VirtualChannel channel) throws IOException, InterruptedException {
    -            readFromTar(FilePath.this.getName(), dir, compression.extract(in));
    +            readFromTar(name, dir, compression.extract(in));
                 return null;
             }
             private static final long serialVersionUID = 1L;
         }
    -    private class UntarLocal extends SecureFileCallable<Void> {
    +    private static class UntarLocal extends SecureFileCallable<Void> {
             private final TarCompression compression;
    -        UntarLocal(TarCompression compression) {
    +        private final FilePath filePath;
    +
    +        UntarLocal(FilePath source, TarCompression compression) {
    +            this.filePath = source;
                 this.compression = compression;
             }
             @Override
             public Void invoke(File dir, VirtualChannel channel) throws IOException, InterruptedException {
    -            readFromTar(FilePath.this.getName(), dir, compression.extract(FilePath.this.read()));
    +            readFromTar(this.filePath.getName(), dir, compression.extract(this.filePath.read()));
                 return null;
             }
             private static final long serialVersionUID = 1L;
    +
    +        protected Object readResolve() {
    +            if (REJECT_LOCAL_CALLABLE_DESERIALIZATION) {
    +                throw new IllegalStateException("This callable is not intended to be sent through a channel");
    +            }
    +            return this;
    +        }
         }
     
         /**
    @@ -660,7 +681,7 @@ public void unzipFrom(InputStream _in) throws IOException, InterruptedException
             final InputStream in = new RemoteInputStream(_in, Flag.GREEDY);
             act(new UnzipFrom(in));
         }
    -    private class UnzipFrom extends SecureFileCallable<Void> {
    +    private static class UnzipFrom extends SecureFileCallable<Void> {
             private final InputStream in;
             UnzipFrom(InputStream in) {
                 this.in = in;
    @@ -673,7 +694,7 @@ public Void invoke(File dir, VirtualChannel channel) throws IOException {
             private static final long serialVersionUID = 1L;
         }
     
    -    private void unzip(File dir, InputStream in) throws IOException {
    +    private static void unzip(File dir, InputStream in) throws IOException {
             File tmpFile = File.createTempFile("tmpzip", null); // uses java.io.tmpdir
             try {
                 // TODO why does this not simply use ZipInputStream?
    @@ -685,7 +706,7 @@ private void unzip(File dir, InputStream in) throws IOException {
             }
         }
     
    -    private void unzip(File dir, File zipFile) throws IOException {
    +    private static void unzip(File dir, File zipFile) throws IOException {
             dir = dir.getAbsoluteFile();    // without absolutization, getParentFile below seems to fail
             ZipFile zip = new ZipFile(zipFile);
             Enumeration<ZipEntry> entries = zip.getEntries();
    @@ -734,7 +755,7 @@ private static class Absolutize extends SecureFileCallable<String> {
             private static final long serialVersionUID = 1L;
             @Override
             public String invoke(File f, VirtualChannel channel) throws IOException {
    -            return f.getAbsolutePath();
    +            return stating(f).getAbsolutePath();
             }
         }
     
    @@ -754,7 +775,7 @@ private static class HasSymlink extends SecureFileCallable<Boolean> {
     
             @Override
             public Boolean invoke(File f, VirtualChannel channel) throws IOException {
    -            return isSymlink(f, verificationRoot, noFollowLinks);
    +            return isSymlink(stating(f), verificationRoot, noFollowLinks);
             }
         }
     
    @@ -792,7 +813,7 @@ public boolean accept(File file) {
         public void symlinkTo(final String target, final TaskListener listener) throws IOException, InterruptedException {
             act(new SymlinkTo(target, listener));
         }
    -    private class SymlinkTo extends SecureFileCallable<Void> {
    +    private static class SymlinkTo extends SecureFileCallable<Void> {
             private final String target;
             private final TaskListener listener;
             SymlinkTo(String target, TaskListener listener) {
    @@ -802,8 +823,7 @@ private class SymlinkTo extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             @Override
             public Void invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
    -            symlinking(f);
    -            Util.createSymlink(f.getParentFile(), target, f.getName(), listener);
    +            Util.createSymlink(symlinking(f).getParentFile(), target, f.getName(), listener);
                 return null;
             }
         }
    @@ -818,7 +838,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException, Interrupt
         public String readLink() throws IOException, InterruptedException {
             return act(new ReadLink());
         }
    -    private class ReadLink extends SecureFileCallable<String> {
    +    private static class ReadLink extends SecureFileCallable<String> {
             private static final long serialVersionUID = 1L;
             @Override
             public String invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
    @@ -896,7 +916,7 @@ public void untarFrom(InputStream _in, final TarCompression compression) throws
                 _in.close();
             }
         }
    -    private class UntarFrom extends SecureFileCallable<Void> {
    +    private static class UntarFrom extends SecureFileCallable<Void> {
             private final TarCompression compression;
             private final InputStream in;
             UntarFrom(TarCompression compression, InputStream in) {
    @@ -905,7 +925,7 @@ private class UntarFrom extends SecureFileCallable<Void> {
             }
             @Override
             public Void invoke(File dir, VirtualChannel channel) throws IOException {
    -            readFromTar("input stream",dir, compression.extract(in));
    +            readFromTar("input stream",dir, compression.extract(in)); // #writing etc. are called in #readFromTar
                 return null;
             }
             private static final long serialVersionUID = 1L;
    @@ -1157,7 +1177,7 @@ private <T> T act(final FileCallable<T> callable, ClassLoader cl) throws IOExcep
             if(channel!=null) {
                 // run this on a remote system
                 try {
    -                DelegatingCallable<T,IOException> wrapper = new FileCallableWrapper<>(callable, cl);
    +                DelegatingCallable<T,IOException> wrapper = new FileCallableWrapper<>(callable, cl, this);
                     for (FileCallableWrapperFactory factory : ExtensionList.lookup(FileCallableWrapperFactory.class)) {
                         wrapper = factory.wrap(wrapper);
                     }
    @@ -1233,7 +1253,7 @@ protected void after() {}
          */
         public <T> Future<T> actAsync(final FileCallable<T> callable) throws IOException, InterruptedException {
             try {
    -            DelegatingCallable<T,IOException> wrapper = new FileCallableWrapper<>(callable);
    +            DelegatingCallable<T,IOException> wrapper = new FileCallableWrapper<>(callable, this);
                 for (FileCallableWrapperFactory factory : ExtensionList.lookup(FileCallableWrapperFactory.class)) {
                     wrapper = factory.wrap(wrapper);
                 }
    @@ -1302,7 +1322,7 @@ private static class ToURI extends SecureFileCallable<URI> {
             private static final long serialVersionUID = 1L;
             @Override
             public URI invoke(File f, VirtualChannel channel) {
    -            return f.toURI();
    +            return stating(f).toURI();
             }
         }
     
    @@ -1340,7 +1360,7 @@ public void mkdirs() throws IOException, InterruptedException {
                 throw new IOException("Failed to mkdirs: " + remote);
             }
         }
    -    private class Mkdirs extends SecureFileCallable<Boolean> {
    +    private static class Mkdirs extends SecureFileCallable<Boolean> {
             private static final long serialVersionUID = 1L;
             @Override
             public Boolean invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
    @@ -1366,7 +1386,7 @@ public void deleteSuffixesRecursive() throws IOException, InterruptedException {
         /**
          * Deletes all suffixed directories that are separated by {@link WorkspaceList#COMBINATOR}, including all its contents recursively.
          */
    -    private class DeleteSuffixesRecursive extends SecureFileCallable<Void> {
    +    private static class DeleteSuffixesRecursive extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
     
             @Override
    @@ -1398,7 +1418,7 @@ private static File[] listParentFiles(File f) {
         public void deleteRecursive() throws IOException, InterruptedException {
             act(new DeleteRecursive());
         }
    -    private class DeleteRecursive extends SecureFileCallable<Void> {
    +    private static class DeleteRecursive extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             @Override
             public Void invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1413,7 +1433,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException {
         public void deleteContents() throws IOException, InterruptedException {
             act(new DeleteContents());
         }
    -    private class DeleteContents extends SecureFileCallable<Void> {
    +    private static class DeleteContents extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             @Override
             public Void invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1516,7 +1536,7 @@ public FilePath createTempFile(final String prefix, final String suffix) throws
                 throw new IOException("Failed to create a temp file on "+remote,e);
             }
         }
    -    private class CreateTempFile extends SecureFileCallable<String> {
    +    private static class CreateTempFile extends SecureFileCallable<String> {
             private final String prefix;
             private final String suffix;
             CreateTempFile(String prefix, String suffix) {
    @@ -1526,7 +1546,8 @@ private class CreateTempFile extends SecureFileCallable<String> {
             private static final long serialVersionUID = 1L;
             @Override
             public String invoke(File dir, VirtualChannel channel) throws IOException {
    -            File f = writing(File.createTempFile(prefix, suffix, dir));
    +            creating(new File(dir, prefix + "-security-check-dummy-" + suffix)); // use fake file to check access before creation
    +            File f = creating(File.createTempFile(prefix, suffix, dir));
                 return f.getName();
             }
         }
    @@ -1580,7 +1601,7 @@ public FilePath createTextTempFile(final String prefix, final String suffix, fin
                 throw new IOException("Failed to create a temp file on "+remote,e);
             }
         }
    -    private final class CreateTextTempFile extends SecureFileCallable<String> {
    +    private static class CreateTextTempFile extends SecureFileCallable<String> {
             private static final long serialVersionUID = 1L;
             private final boolean inThisDirectory;
             private final String prefix;
    @@ -1601,6 +1622,7 @@ public String invoke(File dir, VirtualChannel channel) throws IOException {
     
                 File f;
                 try {
    +                creating(new File(dir, prefix + "-security-check-dummy-" + suffix)); // use fake file to check access before creation
                     f = creating(File.createTempFile(prefix, suffix, dir));
                 } catch (IOException e) {
                     throw new IOException("Failed to create a temporary directory in "+dir,e);
    @@ -1642,14 +1664,15 @@ public FilePath createTempDir(final String prefix, final String suffix) throws I
                 throw new IOException("Failed to create a temp directory on "+remote,e);
             }
         }
    -    private class CreateTempDir extends SecureFileCallable<String> {
    +    private static class CreateTempDir extends SecureFileCallable<String> {
             private final String name;
             CreateTempDir(String name) {
                 this.name = name;
             }
                 private static final long serialVersionUID = 1L;
                 @Override
                 public String invoke(File dir, VirtualChannel channel) throws IOException {
    +                mkdirsing(new File(dir, name + "-security-test")); // ensure access
     
                     Path tempPath;
                     final boolean isPosix = FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
    @@ -1661,8 +1684,8 @@ public String invoke(File dir, VirtualChannel channel) throws IOException {
                         tempPath = Files.createTempDirectory(Util.fileToPath(dir), name);
                     }
     
    -                if (tempPath.toFile() == null) {
    -                    throw new IOException("Failed to obtain file from path " + dir + " on " + remote);
    +                if (mkdirsing(tempPath.toFile()) == null) {
    +                    throw new IOException("Failed to obtain file from path " + dir);
                     }
                     return tempPath.toFile().getName();
                 }
    @@ -1677,7 +1700,7 @@ public boolean delete() throws IOException, InterruptedException {
             act(new Delete());
             return true;
         }
    -    private class Delete extends SecureFileCallable<Void> {
    +    private static class Delete extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             @Override
             public Void invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1692,7 +1715,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException {
         public boolean exists() throws IOException, InterruptedException {
             return act(new Exists());
         }
    -    private class Exists extends SecureFileCallable<Boolean> {
    +    private static class Exists extends SecureFileCallable<Boolean> {
             private static final long serialVersionUID = 1L;
             @Override
             public Boolean invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1710,7 +1733,7 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException {
         public long lastModified() throws IOException, InterruptedException {
             return act(new LastModified());
         }
    -    private class LastModified extends SecureFileCallable<Long> {
    +    private static class LastModified extends SecureFileCallable<Long> {
             private static final long serialVersionUID = 1L;
             @Override
             public Long invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1726,7 +1749,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException {
         public void touch(final long timestamp) throws IOException, InterruptedException {
             act(new Touch(timestamp));
         }
    -    private class Touch extends SecureFileCallable<Void> {
    +    private static class Touch extends SecureFileCallable<Void> {
             private final long timestamp;
             Touch(long timestamp) {
                 this.timestamp = timestamp;
    @@ -1750,7 +1773,7 @@ private void setLastModifiedIfPossible(final long timestamp) throws IOException,
                 LOGGER.warning(message);
             }
         }
    -    private class SetLastModified extends SecureFileCallable<String> {
    +    private static class SetLastModified extends SecureFileCallable<String> {
             private final long timestamp;
             SetLastModified(long timestamp) {
                 this.timestamp = timestamp;
    @@ -1777,7 +1800,7 @@ public String invoke(File f, VirtualChannel channel) throws IOException {
         public boolean isDirectory() throws IOException, InterruptedException {
             return act(new IsDirectory());
         }
    -    private final class IsDirectory extends SecureFileCallable<Boolean> {
    +    private static class IsDirectory extends SecureFileCallable<Boolean> {
             private static final long serialVersionUID = 1L;
             @Override
             public Boolean invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1793,7 +1816,7 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException {
         public long length() throws IOException, InterruptedException {
             return act(new Length());
         }
    -    private class Length extends SecureFileCallable<Long> {
    +    private static class Length extends SecureFileCallable<Long> {
             private static final long serialVersionUID = 1L;
             @Override
             public Long invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1808,7 +1831,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException {
         public long getFreeDiskSpace() throws IOException, InterruptedException {
             return act(new GetFreeDiskSpace());
         }
    -    private static class GetFreeDiskSpace extends SecureFileCallable<Long> {
    +    private static class GetFreeDiskSpace extends MasterToSlaveFileCallable<Long> {
             private static final long serialVersionUID = 1L;
             @Override
             public Long invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1823,7 +1846,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException {
         public long getTotalDiskSpace() throws IOException, InterruptedException {
             return act(new GetTotalDiskSpace());
         }
    -    private static class GetTotalDiskSpace extends SecureFileCallable<Long> {
    +    private static class GetTotalDiskSpace extends MasterToSlaveFileCallable<Long> {
             private static final long serialVersionUID = 1L;
             @Override
             public Long invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1838,7 +1861,7 @@ public Long invoke(File f, VirtualChannel channel) throws IOException {
         public long getUsableDiskSpace() throws IOException, InterruptedException {
             return act(new GetUsableDiskSpace());
         }
    -    private static class GetUsableDiskSpace extends SecureFileCallable<Long> {
    +    private static class GetUsableDiskSpace extends MasterToSlaveFileCallable<Long> {
             private static final long serialVersionUID = 1L;
             @Override
             public Long invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1870,7 +1893,7 @@ public void chmod(final int mask) throws IOException, InterruptedException {
             if(!isUnix() || mask==-1)   return;
             act(new Chmod(mask));
         }
    -    private class Chmod extends SecureFileCallable<Void> {
    +    private static class Chmod extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             private final int mask;
             Chmod(int mask) {
    @@ -1909,7 +1932,7 @@ public int mode() throws IOException, InterruptedException, PosixException {
             if(!isUnix())   return -1;
             return act(new Mode());
         }
    -    private class Mode extends SecureFileCallable<Integer> {
    +    private static class Mode extends SecureFileCallable<Integer> {
             private static final long serialVersionUID = 1L;
             @Override
             public Integer invoke(File f, VirtualChannel channel) throws IOException {
    @@ -1979,7 +2002,7 @@ public List<FilePath> list(final FileFilter filter) throws IOException, Interrup
             }
             return act(new ListFilter(filter), (filter != null ? filter : this).getClass().getClassLoader());
         }
    -    private class ListFilter extends SecureFileCallable<List<FilePath>> {
    +    private static class ListFilter extends SecureFileCallable<List<FilePath>> {
             private final FileFilter filter;
             ListFilter(FileFilter filter) {
                 this.filter = filter;
    @@ -2045,7 +2068,7 @@ public FilePath[] list(final String includes, final String excludes) throws IOEx
         public FilePath[] list(final String includes, final String excludes, final boolean defaultExcludes) throws IOException, InterruptedException {
             return act(new ListGlob(includes, excludes, defaultExcludes));
         }
    -    private class ListGlob extends SecureFileCallable<FilePath[]> {
    +    private static class ListGlob extends SecureFileCallable<FilePath[]> {
             private final String includes;
             private final String excludes;
             private final boolean defaultExcludes;
    @@ -2061,7 +2084,7 @@ public FilePath[] invoke(File f, VirtualChannel channel) throws IOException {
     
                     FilePath[] r = new FilePath[files.length];
                     for( int i=0; i<r.length; i++ )
    -                    r[i] = new FilePath(new File(f,files[i]));
    +                    r[i] = new FilePath(stating(new File(f,files[i])));
     
                     return r;
                 }
    @@ -2184,7 +2207,7 @@ private static boolean isFileAncestorSymlink(File file, String root) {
             return false;
         }
     
    -    private class Read extends SecureFileCallable<Void> {
    +    private static class Read extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             private final Pipe p;
             private String verificationRoot;
    @@ -2251,7 +2274,7 @@ public int read(byte[] b) throws IOException {
             return new java.util.zip.GZIPInputStream(p.getIn());
         }
         
    -    private class OffsetPipeSecureFileCallable extends SecureFileCallable<Void> {
    +    private static class OffsetPipeSecureFileCallable extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             
             private Pipe p;
    @@ -2284,7 +2307,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException {
         public String readToString() throws IOException, InterruptedException {
             return act(new ReadToString());
         } 
    -    private final class ReadToString extends SecureFileCallable<String> {
    +    private static class ReadToString extends SecureFileCallable<String> {
             private static final long serialVersionUID = 1L;       
             @Override
             public String invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
    @@ -2308,12 +2331,12 @@ public OutputStream write() throws IOException, InterruptedException {
             if(channel==null) {
                 File f = new File(remote).getAbsoluteFile();
                 mkdirs(f.getParentFile());
    -            return Files.newOutputStream(fileToPath(writing(f)));
    +            return Files.newOutputStream(fileToPath(writing(f))); // TODO #writing seems unnecessary on a local file
             }
     
             return act(new WritePipe());
         }
    -    private class WritePipe extends SecureFileCallable<OutputStream> {
    +    private static class WritePipe extends SecureFileCallable<OutputStream> {
                 private static final long serialVersionUID = 1L;
                 @Override
                 public OutputStream invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
    @@ -2333,7 +2356,7 @@ public OutputStream invoke(File f, VirtualChannel channel) throws IOException, I
         public void write(final String content, final String encoding) throws IOException, InterruptedException {
             act(new Write(encoding, content));
         }
    -    private class Write extends SecureFileCallable<Void> {
    +    private static class Write extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 1L;
             private final String encoding;
             private final String content;
    @@ -2359,7 +2382,7 @@ public Void invoke(File f, VirtualChannel channel) throws IOException {
         public String digest() throws IOException, InterruptedException {
             return act(new Digest());
         }
    -    private class Digest extends SecureFileCallable<String> {
    +    private static class Digest extends SecureFileCallable<String> {
             private static final long serialVersionUID = 1L;
             @Override
             public String invoke(File f, VirtualChannel channel) throws IOException {
    @@ -2377,15 +2400,15 @@ public void renameTo(final FilePath target) throws IOException, InterruptedExcep
         	}
             act(new RenameTo(target));
         }
    -    private class RenameTo extends SecureFileCallable<Void> {
    +    private static class RenameTo extends SecureFileCallable<Void> {
             private final FilePath target;
             RenameTo(FilePath target) {
                 this.target = target;
             }
             private static final long serialVersionUID = 1L;
             @Override
             public Void invoke(File f, VirtualChannel channel) throws IOException {
    -            Files.move(fileToPath(reading(f)), fileToPath(creating(new File(target.remote))), LinkOption.NOFOLLOW_LINKS);
    +            Files.move(fileToPath(deleting(reading(f))), fileToPath(writing(creating(new File(target.remote)))), LinkOption.NOFOLLOW_LINKS);
                 return null;
             }
         }
    @@ -2401,7 +2424,7 @@ public void moveAllChildrenTo(final FilePath target) throws IOException, Interru
             }
             act(new MoveAllChildrenTo(target));
         }
    -    private class MoveAllChildrenTo extends SecureFileCallable<Void> {
    +    private static class MoveAllChildrenTo extends SecureFileCallable<Void> {
             private final FilePath target;
             MoveAllChildrenTo(FilePath target) {
                 this.target = target;
    @@ -2412,14 +2435,14 @@ public Void invoke(File f, VirtualChannel channel) throws IOException {
                     // JENKINS-16846: if f.getName() is the same as one of the files/directories in f,
                     // then the rename op will fail
                     File tmp = new File(f.getAbsolutePath()+".__rename");
    -                if (!f.renameTo(tmp))
    +                if (!deleting(f).renameTo(creating(tmp)))
                         throw new IOException("Failed to rename "+f+" to "+tmp);
     
                     File t = new File(target.getRemote());
     
                     for(File child : reading(tmp).listFiles()) {
                         File target = new File(t, child.getName());
    -                    if(!stating(child).renameTo(creating(target)))
    +                    if(!deleting(reading(child)).renameTo(writing(creating(target))))
                             throw new IOException("Failed to rename "+child+" to "+target);
                     }
                     deleting(tmp).delete();
    @@ -2456,7 +2479,7 @@ public void copyToWithPermission(FilePath target) throws IOException, Interrupte
             target.chmod(mode());
             target.setLastModifiedIfPossible(lastModified());
         }
    -    private class CopyToWithPermission extends SecureFileCallable<Void> {
    +    private static class CopyToWithPermission extends SecureFileCallable<Void> {
             private final FilePath target;
             CopyToWithPermission(FilePath target) {
                 this.target = target;
    @@ -2484,7 +2507,7 @@ public void copyTo(OutputStream os) throws IOException, InterruptedException {
             // this is needed because I/O operation is asynchronous
             syncIO();
         }
    -    private class CopyTo extends SecureFileCallable<Void> {
    +    private static class CopyTo extends SecureFileCallable<Void> {
             private static final long serialVersionUID = 4088559042349254141L;
             private final OutputStream out;
             CopyTo(OutputStream out) {
    @@ -2616,7 +2639,7 @@ public int copyRecursiveTo(final DirScanner scanner, final FilePath target, fina
                 // local -> remote copy
                 final Pipe pipe = Pipe.createLocalToRemote();
     
    -            Future<Void> future = target.actAsync(new ReadToTar(pipe, description, compression));
    +            Future<Void> future = target.actAsync(new ReadFromTar(target, pipe, description, compression));
                 Future<Integer> future2 = actAsync(new WriteToTar(scanner, pipe, compression));
                 try {
                     // JENKINS-9540 in case the reading side failed, report that error first
    @@ -2662,7 +2685,7 @@ private IOException ioWithCause(ExecutionException e) {
                     ;
         }
     
    -    private class CopyRecursiveLocal extends SecureFileCallable<Integer> {
    +    private static class CopyRecursiveLocal extends SecureFileCallable<Integer> {
             private final FilePath target;
             private final DirScanner scanner;
             CopyRecursiveLocal(FilePath target, DirScanner scanner) {
    @@ -2675,7 +2698,9 @@ public Integer invoke(File base, VirtualChannel channel) throws IOException {
                 if (!base.exists()) {
                     return 0;
                 }
    -            assert target.channel == null;
    +            if (target.channel != null) {
    +                throw new IllegalStateException("Expected null channel for " + target);
    +            }
                 final File dest = new File(target.remote);
                 final AtomicInteger count = new AtomicInteger();
                 scanner.scan(base, reading(new FileVisitor() {
    @@ -2729,12 +2754,14 @@ public void visitSymlink(File link, String target, String relativePath) throws I
                 return count.get();
             }
         }
    -    private class ReadToTar extends SecureFileCallable<Void> {
    +    private static class ReadFromTar extends SecureFileCallable<Void> {
             private final Pipe pipe;
             private final String description;
             private final TarCompression compression;
    +        private final FilePath target;
     
    -        ReadToTar(Pipe pipe, String description, @NonNull TarCompression compression) {
    +        ReadFromTar(FilePath target, Pipe pipe, String description, @NonNull TarCompression compression) {
    +            this.target = target;
                 this.pipe = pipe;
                 this.description = description;
                 this.compression = compression;
    @@ -2743,12 +2770,12 @@ private class ReadToTar extends SecureFileCallable<Void> {
             @Override
             public Void invoke(File f, VirtualChannel channel) throws IOException {
                 try (InputStream in = pipe.getIn()) {
    -                readFromTar(remote + '/' + description, f, compression.extract(in));
    +                readFromTar(target.remote + '/' + description, f, compression.extract(in));
                     return null;
                 }
             }
         }
    -    private class WriteToTar extends SecureFileCallable<Integer> {
    +    private static class WriteToTar extends SecureFileCallable<Integer> {
             private final DirScanner scanner;
             private final Pipe pipe;
             private final TarCompression compression;
    @@ -2760,10 +2787,10 @@ private class WriteToTar extends SecureFileCallable<Integer> {
             private static final long serialVersionUID = 1L;
             @Override
             public Integer invoke(File f, VirtualChannel channel) throws IOException, InterruptedException {
    -            return writeToTar(new File(remote), scanner, compression.compress(pipe.getOut()));
    +            return writeToTar(reading(f), scanner, compression.compress(pipe.getOut()));
             }
         }
    -    private class CopyRecursiveRemoteToLocal extends SecureFileCallable<Integer> {
    +    private static class CopyRecursiveRemoteToLocal extends SecureFileCallable<Integer> {
             private static final long serialVersionUID = 1L;
             private final Pipe pipe;
             private final DirScanner scanner;
    @@ -2776,7 +2803,7 @@ private class CopyRecursiveRemoteToLocal extends SecureFileCallable<Integer> {
             @Override
             public Integer invoke(File f, VirtualChannel channel) throws IOException {
                 try (OutputStream out = pipe.getOut()) {
    -                return writeToTar(f, scanner, compression.compress(out));
    +                return writeToTar(reading(f), scanner, compression.compress(out));
                 }
             }
         }
    @@ -2808,7 +2835,7 @@ public int tar(OutputStream out, DirScanner scanner) throws IOException, Interru
          * @return
          *      number of files/directories that are written.
          */
    -    private Integer writeToTar(File baseDir, DirScanner scanner, OutputStream out) throws IOException {
    +    private static Integer writeToTar(File baseDir, DirScanner scanner, OutputStream out) throws IOException {
             Archiver tw = ArchiverFactory.TAR.create(out);
             try {
                 scanner.scan(baseDir,reading(tw));
    @@ -2822,14 +2849,15 @@ private Integer writeToTar(File baseDir, DirScanner scanner, OutputStream out) t
          * Reads from a tar stream and stores obtained files to the base dir.
          * Supports large files > 10 GB since 1.627 when this was migrated to use commons-compress.
          */
    -    private void readFromTar(String name, File baseDir, InputStream in) throws IOException {
    +    private static void readFromTar(String name, File baseDir, InputStream in) throws IOException {
    +        baseDir = baseDir.getCanonicalFile();
     
             // TarInputStream t = new TarInputStream(in);
             try (TarArchiveInputStream t = new TarArchiveInputStream(in)) {
                 TarArchiveEntry te;
                 while ((te = t.getNextTarEntry()) != null) {
    -                File f = new File(baseDir, te.getName());
    -                if (!f.toPath().normalize().startsWith(baseDir.toPath())) {
    +                File f = new File(baseDir, te.getName()).getCanonicalFile();
    +                if (!f.toPath().startsWith(baseDir.toPath())) {
                         throw new IOException(
                                 "Tar " + name + " contains illegal file name that breaks out of the target directory: " + te.getName());
                     }
    @@ -2838,12 +2866,11 @@ private void readFromTar(String name, File baseDir, InputStream in) throws IOExc
                     } else {
                         File parent = f.getParentFile();
                         if (parent != null) mkdirs(parent);
    -                    writing(f);
     
                         if (te.isSymbolicLink()) {
    -                        new FilePath(f).symlinkTo(te.getLinkName(), TaskListener.NULL);
    +                        new FilePath(symlinking(f)).symlinkTo(te.getLinkName(), TaskListener.NULL);
                         } else {
    -                        IOUtils.copy(t, f);
    +                        IOUtils.copy(t, writing(f));
     
                             f.setLastModified(te.getModTime().getTime());
                             int mode = te.getMode() & 0777;
    @@ -3280,14 +3307,12 @@ private void readObject(ObjectInputStream ois) throws IOException, ClassNotFound
             ois.defaultReadObject();
             if(ois.readBoolean()) {
                 this.channel = channel;
    -            this.filter = null;
             } else {
                 this.channel = null;
                 // If the remote channel wants us to create a FilePath that points to a local file,
                 // we need to make sure the access control takes place.
    -            // This covers the immediate case of FileCallables taking FilePath into reference closure implicitly,
    -            // but it also covers more general case of FilePath sent as a return value or argument.
    -            this.filter = SoloFilePathFilter.wrap(FilePathFilter.current());
    +            // Any FileCallables acting on a deserialized FilePath need to ensure they're subjecting it to
    +            // access control checks like #reading(File) etc.
             }
         }
     
    @@ -3301,24 +3326,27 @@ private void readObject(ObjectInputStream ois) throws IOException, ClassNotFound
         /**
          * Adapts {@link FileCallable} to {@link Callable}.
          */
    -    private class FileCallableWrapper<T> implements DelegatingCallable<T,IOException> {
    +    private static class FileCallableWrapper<T> implements DelegatingCallable<T,IOException> {
             private final FileCallable<T> callable;
             private transient ClassLoader classLoader;
    +        private final FilePath filePath;
     
    -        FileCallableWrapper(FileCallable<T> callable) {
    +        FileCallableWrapper(FileCallable<T> callable, FilePath filePath) {
                 this.callable = callable;
                 this.classLoader = callable.getClass().getClassLoader();
    +            this.filePath = filePath;
             }
     
    -        private FileCallableWrapper(FileCallable<T> callable, ClassLoader classLoader) {
    +        private FileCallableWrapper(FileCallable<T> callable, ClassLoader classLoader, FilePath filePath) {
                 this.callable = callable;
                 this.classLoader = classLoader;
    +            this.filePath = filePath;
             }
     
             @Override
             public T call() throws IOException {
                 try {
    -                return callable.invoke(new File(remote), Channel.current());
    +                return callable.invoke(new File(filePath.remote), filePath.channel);
                 } catch (InterruptedException e) {
                     throw new TunneledInterruptedException(e);
                 }
    @@ -3413,15 +3441,16 @@ public ExplicitlySpecifiedDirScanner(Map<String,String> files) {
         @NonNull
         public static final LocalChannel localChannel = new LocalChannel(threadPoolForRemoting);
     
    -    private @NonNull SoloFilePathFilter filterNonNull() {
    -        return filter!=null ? filter : UNRESTRICTED;
    +    private static @NonNull SoloFilePathFilter filterNonNull() {
    +        final SoloFilePathFilter filter = SoloFilePathFilter.wrap(FilePathFilter.current());
    +        return filter != null ? filter : UNRESTRICTED;
         }
     
         /**
          * Wraps {@link FileVisitor} to notify read access to {@link FilePathFilter}.
          */
    -    private FileVisitor reading(final FileVisitor v) {
    -        final FilePathFilter filter = FilePathFilter.current();
    +    private static FileVisitor reading(final FileVisitor v) {
    +        final FilePathFilter filter = SoloFilePathFilter.wrap(FilePathFilter.current());
             if (filter==null)    return v;
     
             return new FileVisitor() {
    @@ -3470,31 +3499,31 @@ public boolean understandsSymlink() {
         /**
          * Pass through 'f' after ensuring that we can read that file.
          */
    -    private File reading(File f) {
    +    private static File reading(File f) {
             filterNonNull().read(f);
             return f;
         }
     
         /**
          * Pass through 'f' after ensuring that we can access the file attributes.
          */
    -    private File stating(File f) {
    +    private static File stating(File f) {
             filterNonNull().stat(f);
             return f;
         }
     
         /**
          * Pass through 'f' after ensuring that we can create that file/dir.
          */
    -    private File creating(File f) {
    +    private static File creating(File f) {
             filterNonNull().create(f);
             return f;
         }
     
         /**
          * Pass through 'f' after ensuring that we can write to that file.
          */
    -    private File writing(File f) {
    +    private static File writing(File f) {
             FilePathFilter filter = filterNonNull();
             if (!f.exists())
                 filter.create(f);
    @@ -3505,7 +3534,7 @@ private File writing(File f) {
         /**
          * Pass through 'f' after ensuring that we can create that symlink.
          */
    -    private File symlinking(File f) {
    +    private static File symlinking(File f) {
             FilePathFilter filter = filterNonNull();
             if (!f.exists())
                 filter.create(f);
    @@ -3516,24 +3545,40 @@ private File symlinking(File f) {
         /**
          * Pass through 'f' after ensuring that we can delete that file.
          */
    -    private File deleting(File f) {
    +    private static File deleting(File f) {
             filterNonNull().delete(f);
             return f;
         }
     
    -    private boolean mkdirs(File dir) throws IOException {
    +    /**
    +     * Pass through 'f' after ensuring that we can mkdirs that directory.
    +     */
    +    private static File mkdirsing(File f) {
    +        filterNonNull().mkdirs(f);
    +        return f;
    +    }
    +
    +    private static boolean mkdirs(File dir) throws IOException {
             if (dir.exists())   return false;
     
    -        filterNonNull().mkdirs(dir);
    +        File reference = dir;
    +        while (reference != null && !reference.exists()) {
    +            filterNonNull().mkdirs(reference);
    +            reference = reference.getParentFile();
    +        }
             Files.createDirectories(fileToPath(dir));
             return true;
         }
     
    -    private File mkdirsE(File dir) throws IOException {
    +    private static File mkdirsE(File dir) throws IOException {
             if (dir.exists()) {
                 return dir;
             }
    -        filterNonNull().mkdirs(dir);
    +        File reference = dir;
    +        while (reference != null && !reference.exists()) {
    +            filterNonNull().mkdirs(reference);
    +            reference = reference.getParentFile();
    +        }
             return IOUtils.mkdirs(dir);
         }
     
    @@ -3561,7 +3606,7 @@ public Boolean invoke(@NonNull File parentFile, @NonNull VirtualChannel channel)
                     throw new IllegalArgumentException("Only a relative path is supported, the given path is absolute: " + potentialChildRelativePath);
                 }
         
    -            Path parentAbsolutePath = Util.fileToPath(parentFile.getAbsoluteFile());
    +            Path parentAbsolutePath = Util.fileToPath(stating(parentFile).getAbsoluteFile());
                 Path parentRealPath;
                 try {
                     if (Functions.isWindows()) {
    
  • core/src/main/java/jenkins/security/s2m/FilePathRuleConfig.java+20 4 modified
    @@ -5,12 +5,16 @@
     import hudson.Functions;
     import hudson.model.Failure;
     import java.io.File;
    +import java.io.IOException;
    +import java.io.UncheckedIOException;
     import java.util.ArrayList;
     import java.util.Arrays;
     import java.util.Collections;
     import java.util.HashSet;
     import java.util.List;
     import java.util.Set;
    +import java.util.logging.Level;
    +import java.util.logging.Logger;
     import java.util.regex.Matcher;
     import java.util.regex.Pattern;
     import jenkins.model.Jenkins;
    @@ -21,6 +25,9 @@
      * @author Kohsuke Kawaguchi
      */
     class FilePathRuleConfig extends ConfigDirectory<FilePathRule,List<FilePathRule>> {
    +
    +    private static final Logger LOGGER = Logger.getLogger(FilePathRuleConfig.class.getName());
    +
         FilePathRuleConfig(File file) {
             super(file);
         }
    @@ -40,10 +47,17 @@ protected FilePathRule parse(String line) {
             line = line.trim();
             if (line.isEmpty())     return null;
     
    +        // TODO This does not support custom build dir configuration (Jenkins#getRawBuildsDir() etc.)
             line = line.replace("<BUILDDIR>","<JOBDIR>/builds/<BUILDID>");
             line = line.replace("<BUILDID>","(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_[0-9][0-9]-[0-9][0-9]-[0-9][0-9]|[0-9]+)");
             line = line.replace("<JOBDIR>","<JENKINS_HOME>/jobs/.+");
    -        line = line.replace("<JENKINS_HOME>","\\Q"+Jenkins.get().getRootDir().getPath()+"\\E");
    +        final File jenkinsHome = Jenkins.get().getRootDir();
    +        try {
    +            line = line.replace("<JENKINS_HOME>","\\Q" + jenkinsHome.getCanonicalPath() + "\\E");
    +        } catch (IOException e) {
    +            LOGGER.log(Level.WARNING, e, () -> "Failed to determine canonical path to Jenkins home directory, falling back to configured value: " + jenkinsHome.getPath());
    +            line = line.replace("<JENKINS_HOME>","\\Q" + jenkinsHome.getPath() + "\\E");
    +        }
     
             // config file is always /-separated even on Windows, so bring it back to \-separation.
             // This is done in the context of regex, so it has to be \\, which means in the source code it is \\\\
    @@ -77,9 +91,11 @@ public boolean checkFileAccess(String op, File path) throws SecurityException {
             for (FilePathRule rule : get()) {
                 if (rule.op.matches(op)) {
                     if (pathStr==null) {
    -                    // do not canonicalize, so that JENKINS_HOME that spans across
    -                    // multiple volumes via symlinks can look logically like one unit.
    -                    pathStr = path.getPath();
    +                    try {
    +                        pathStr = path.getCanonicalPath();
    +                    } catch (IOException ex) {
    +                        throw new UncheckedIOException(ex);
    +                    }
                         if (isWindows())    // Windows accepts '/' as separator, but for rule matching we want to normalize for consistent comparison
                             pathStr = pathStr.replace('/','\\');
                     }
    
  • core/src/main/java/jenkins/SoloFilePathFilter.java+34 4 modified
    @@ -1,8 +1,16 @@
     package jenkins;
     
     import edu.umd.cs.findbugs.annotations.Nullable;
    +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
     import hudson.FilePath;
    +import jenkins.util.SystemProperties;
    +import org.kohsuke.accmod.Restricted;
    +import org.kohsuke.accmod.restrictions.NoExternalUse;
    +
     import java.io.File;
    +import java.util.UUID;
    +import java.util.logging.Level;
    +import java.util.logging.Logger;
     
     /**
      * Variant of {@link FilePathFilter} that assumes it is the sole actor.
    @@ -13,6 +21,13 @@
      * @author Kohsuke Kawaguchi
      */
     public final class SoloFilePathFilter extends FilePathFilter {
    +
    +    private static final Logger LOGGER = Logger.getLogger(SoloFilePathFilter.class.getName());
    +
    +    @SuppressFBWarnings("MS_SHOULD_BE_FINAL")
    +    @Restricted(NoExternalUse.class)
    +    public static /* non-final for Groovy */ boolean REDACT_ERRORS = SystemProperties.getBoolean(SoloFilePathFilter.class.getName() + ".redactErrors", true);
    +
         private final FilePathFilter base;
     
         private SoloFilePathFilter(FilePathFilter base) {
    @@ -28,8 +43,17 @@ private SoloFilePathFilter(FilePathFilter base) {
         }
     
         private boolean noFalse(String op, File f, boolean b) {
    -        if (!b)
    -            throw new SecurityException("agent may not " + op + " " + f+"\nSee https://www.jenkins.io/redirect/security-144 for more details");
    +        if (!b) {
    +            final String detailedMessage = "Agent may not '" + op + "' at '" + f + "'. See https://www.jenkins.io/redirect/security-144 for more information.";
    +            if (REDACT_ERRORS) {
    +                // We may end up trying to access file paths indirectly, e.g. FilePath#listFiles starts in an allowed dir but follows symlinks outside, so do not disclose paths in error message
    +                UUID uuid = UUID.randomUUID();
    +                LOGGER.log(Level.WARNING, () -> uuid + ": " + detailedMessage);
    +                throw new SecurityException("Agent may not access a file path. See the system log for more details about the error ID '" + uuid + "' and https://www.jenkins.io/redirect/security-144 for more information.");
    +            } else {
    +                throw new SecurityException(detailedMessage);
    +            }
    +        }
             return true;
         }
         
    @@ -49,12 +73,18 @@ public boolean write(File f) throws SecurityException {
     
         @Override
         public boolean symlink(File f) throws SecurityException {
    -        return noFalse("symlink",f,base.write(normalize(f)));
    +        return noFalse("symlink",f,base.symlink(normalize(f)));
         }
     
         @Override
         public boolean mkdirs(File f) throws SecurityException {
    -        return noFalse("mkdirs",f,base.mkdirs(normalize(f)));
    +        // mkdirs is special because it could operate on parents of the specified path
    +        File reference = normalize(f);
    +        while (reference != null && !reference.exists()) {
    +            noFalse("mkdirs", f, base.mkdirs(reference)); // Pass f as reference into the error to be vague
    +            reference = reference.getParentFile();
    +        }
    +        return true;
         }
     
         @Override
    
  • test/src/test/java/jenkins/security/s2m/AdminFilePathFilterTest.java+13 1 modified
    @@ -24,10 +24,13 @@
     
     package jenkins.security.s2m;
     
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.containsString;
     import static org.junit.Assert.assertEquals;
     import static org.junit.Assert.assertFalse;
     import static org.junit.Assert.assertTrue;
     import static org.junit.Assert.fail;
    +import static org.jvnet.hudson.test.LoggerRule.recorded;
     
     import hudson.FilePath;
     import hudson.model.Slave;
    @@ -37,19 +40,25 @@
     import java.io.PrintWriter;
     import java.io.StringWriter;
     import java.lang.reflect.Field;
    +import java.util.logging.Level;
     import javax.inject.Inject;
    +import jenkins.SoloFilePathFilter;
     import org.jenkinsci.remoting.RoleChecker;
     import org.junit.Before;
     import org.junit.Rule;
     import org.junit.Test;
     import org.jvnet.hudson.test.Issue;
     import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.LoggerRule;
     
     public class AdminFilePathFilterTest {
     
         @Rule
         public JenkinsRule r = new JenkinsRule();
     
    +    @Rule
    +    public LoggerRule logging = new LoggerRule().record(SoloFilePathFilter.class, Level.WARNING);
    +
         @Inject
         AdminWhitelistRule rule;
     
    @@ -186,6 +195,7 @@ private void checkSlave_can_readFile(Slave s, FilePath target) throws Exception
         
         private void checkSlave_cannot_readFile(Slave s, FilePath target) throws Exception {
             try {
    +            logging.capture(10);
                 s.getChannel().call(new ReadFileS2MCallable(target));
                 fail("Slave should not be able to read file in " + target.getRemote());
             } catch (IOException e){
    @@ -194,7 +204,9 @@ private void checkSlave_cannot_readFile(Slave s, FilePath target) throws Excepti
                 SecurityException se = (SecurityException) t;
                 StringWriter sw = new StringWriter();
                 se.printStackTrace(new PrintWriter(sw));
    -            assertTrue(sw.toString().contains("agent may not read"));
    +            assertTrue(sw.toString().contains("Agent may not access a file path"));
    +
    +            assertThat(logging, recorded(containsString("Agent may not 'read' at")));
             }
         }
         
    
  • test/src/test/java/jenkins/security/Security2455Test.java+756 0 added
    @@ -0,0 +1,756 @@
    +package jenkins.security;
    +
    +import static org.hamcrest.CoreMatchers.containsString;
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.not;
    +import static org.junit.Assert.assertEquals;
    +import static org.junit.Assert.assertFalse;
    +import static org.junit.Assert.assertNotNull;
    +import static org.junit.Assert.assertTrue;
    +import static org.junit.Assert.fail;
    +import static org.junit.Assume.assumeFalse;
    +import static org.jvnet.hudson.test.LoggerRule.recorded;
    +
    +import hudson.ExtensionList;
    +import hudson.FilePath;
    +import hudson.Functions;
    +import hudson.Util;
    +import hudson.model.Cause;
    +import hudson.model.FreeStyleBuild;
    +import hudson.model.Node;
    +import hudson.model.TaskListener;
    +import hudson.remoting.VirtualChannel;
    +import java.io.File;
    +import java.io.FileReader;
    +import java.io.IOException;
    +import java.lang.reflect.Constructor;
    +import java.net.URI;
    +import java.nio.file.Files;
    +import java.util.Arrays;
    +import java.util.List;
    +import java.util.Objects;
    +import java.util.logging.Level;
    +import jenkins.SlaveToMasterFileCallable;
    +import jenkins.SoloFilePathFilter;
    +import jenkins.agents.AgentComputerUtil;
    +import jenkins.security.s2m.AdminWhitelistRule;
    +import org.apache.commons.io.IOUtils;
    +import org.apache.commons.io.output.NullOutputStream;
    +import org.junit.Before;
    +import org.junit.Rule;
    +import org.junit.Test;
    +import org.junit.function.ThrowingRunnable;
    +import org.jvnet.hudson.test.FlagRule;
    +import org.jvnet.hudson.test.Issue;
    +import org.jvnet.hudson.test.JenkinsRule;
    +import org.jvnet.hudson.test.LoggerRule;
    +import org.jvnet.hudson.test.recipes.LocalData;
    +
    +@SuppressWarnings("ThrowableNotThrown")
    +@Issue("SECURITY-2455")
    +public class Security2455Test {
    +
    +    // TODO After merge, reference the class directly
    +    private static final String SECURITY_2428_KILLSWITCH = "jenkins.security.s2m.RunningBuildFilePathFilter.FAIL";
    +
    +    @Rule
    +    public final FlagRule<String> flagRule = FlagRule.systemProperty(SECURITY_2428_KILLSWITCH, "false");
    +
    +    @Rule
    +    public JenkinsRule j = new JenkinsRule();
    +
    +    @Rule
    +    public LoggerRule logging = new LoggerRule().record(SoloFilePathFilter.class, Level.WARNING);
    +
    +    @Before
    +    public void setup() {
    +        ExtensionList.lookupSingleton(AdminWhitelistRule.class).setMasterKillSwitch(false);
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2427")
    +    public void mkdirsParentsTest() {
    +        final File buildStuff = new File(j.jenkins.getRootDir(), "job/nonexistent/builds/1/foo/bar");
    +        logging.capture(10);
    +        SecurityException ex = assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkdirsParentsCallable(buildStuff)));
    +        assertThat(logging, recorded(containsString("foo/bar")));
    +        assertThat(ex.getMessage(), not(containsString("foo/bar"))); // test error redaction
    +
    +        SoloFilePathFilter.REDACT_ERRORS = false;
    +        try {
    +            SecurityException ex2 = assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MkdirsParentsCallable(buildStuff)));
    +            assertThat(ex2.getMessage(), containsString("foo/bar")); // test error redaction
    +        } finally {
    +            SoloFilePathFilter.REDACT_ERRORS = true;
    +        }
    +    }
    +    private static class MkdirsParentsCallable extends MasterToSlaveCallable<String, Exception> {
    +        private final File file;
    +
    +        private MkdirsParentsCallable(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(this.file).mkdirs();
    +            return null;
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2444")
    +    public void testNonCanonicalPath() throws Exception {
    +        assumeFalse(Functions.isWindows());
    +        final FreeStyleBuild build = j.createFreeStyleProject().scheduleBuild2(0, new Cause.UserIdCause()).waitForStart();
    +        j.waitForCompletion(build);
    +        final File link = new File(build.getRootDir(), "link");
    +        final File secrets = new File(j.jenkins.getRootDir(), "secrets/master.key");
    +        Files.createSymbolicLink(link.toPath(), secrets.toPath());
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ReadToStringCallable(link)));
    +    }
    +    @Test
    +    @Issue("SECURITY-2444")
    +    public void testNonCanonicalPathOnController() throws Exception {
    +        assumeFalse(Functions.isWindows());
    +        final FreeStyleBuild build = j.createFreeStyleProject().scheduleBuild2(0, new Cause.UserIdCause()).waitForStart();
    +        j.waitForCompletion(build);
    +        final File link = new File(build.getRootDir(), "link");
    +        final File secrets = new File(j.jenkins.getRootDir(), "secrets/master.key");
    +        Files.createSymbolicLink(link.toPath(), secrets.toPath());
    +        String result = FilePath.localChannel.call(new ReadToStringCallable(link));
    +        assertEquals(IOUtils.readLines(new FileReader(secrets)).get(0), result);
    +    }
    +
    +    private static class ReadToStringCallable extends MasterToSlaveCallable<String, Exception> {
    +
    +        final String abs;
    +
    +        ReadToStringCallable(File link) {
    +            abs = link.getPath();
    +        }
    +
    +        @Override
    +        public String call() throws IOException {
    +            FilePath p = toFilePathOnController(new File(abs));
    +            try {
    +                return p.readToString();
    +            } catch (InterruptedException e) {
    +                throw new IOException(e);
    +            }
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue({"SECURITY-2446", "SECURITY-2531"})
    +    // $ tar tvf symlink.tar
    +    // lrwxr-xr-x  0 501    20          0 Oct  5 09:50 foo -> ../../../../secrets
    +    @LocalData
    +    public void testUntaringSymlinksFails() throws Exception {
    +        final FreeStyleBuild freeStyleBuild = j.buildAndAssertSuccess(j.createFreeStyleProject());
    +        final File symlinkTarFile = new File(j.jenkins.getRootDir(), "symlink.tar");
    +        final File untarTargetFile = new File(freeStyleBuild.getRootDir(), "foo");
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new UntarFileCallable(symlinkTarFile, untarTargetFile)));
    +    }
    +    private static final class UntarFileCallable extends MasterToSlaveCallable<Integer, Exception> {
    +        private final File source;
    +        private final File destination;
    +
    +        private UntarFileCallable(File source, File destination) {
    +            this.source = source;
    +            this.destination = destination;
    +        }
    +
    +        @Override
    +        public Integer call() throws Exception {
    +            final FilePath sourceFilePath = new FilePath(source);
    +            final FilePath destinationFilePath = toFilePathOnController(destination);
    +            sourceFilePath.untar(destinationFilePath, FilePath.TarCompression.NONE);
    +            return 1;
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2453")
    +    public void testTarSymlinksThatAreSafe() throws Exception {
    +        assumeFalse(Functions.isWindows());
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        // We cannot touch the build dir itself
    +        final File innerDir = new File(buildDir, "dir");
    +        final File innerDir2 = new File(buildDir, "dir2");
    +        assertTrue(innerDir.mkdirs());
    +        assertTrue(innerDir2.mkdirs());
    +        assertTrue(new File(innerDir2, "the-file").createNewFile());
    +        Util.createSymlink(innerDir, "../dir2", "link", TaskListener.NULL);
    +        assertTrue(new File(innerDir, "link/the-file").exists());
    +        final int files = invokeOnAgent(new TarCaller(innerDir));
    +        assertEquals(1, files);
    +    }
    +    @Test
    +    @Issue("SECURITY-2453")
    +    public void testTarSymlinksOutsideAllowedDirs() throws Exception {
    +        assumeFalse(Functions.isWindows());
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        // We cannot touch the build dir itself
    +        final File innerDir = new File(buildDir, "dir");
    +        assertTrue(innerDir.mkdirs());
    +        Util.createSymlink(innerDir, "../../../../../secrets", "secrets-link", TaskListener.NULL);
    +        assertTrue(new File(innerDir, "secrets-link/master.key").exists());
    +        logging.capture(10);
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new TarCaller(innerDir)));
    +        assertThat(logging, recorded(containsString("filepath-filters.d")));
    +    }
    +
    +    private static class TarCaller extends MasterToSlaveCallable<Integer, Exception> {
    +        private final File root;
    +
    +        private TarCaller(File root) {
    +            this.root = root;
    +        }
    +
    +        @Override
    +        public Integer call() throws Exception {
    +            return toFilePathOnController(root).tar(NullOutputStream.NULL_OUTPUT_STREAM, "**");
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2484")
    +    public void zipTest() {
    +        final File secrets = new File(j.jenkins.getRootDir(), "secrets");
    +        assertTrue(secrets.exists());
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ZipTestCallable(secrets)));
    +    }
    +    @Test
    +    @Issue("SECURITY-2484")
    +    public void zipTestController() throws Exception {
    +        final File secrets = new File(j.jenkins.getRootDir(), "secrets");
    +        assertTrue(secrets.exists());
    +        FilePath.localChannel.call(new ZipTestCallable(secrets));
    +    }
    +
    +    private static class ZipTestCallable extends MasterToSlaveCallable<String, Exception> {
    +        private final File file;
    +
    +        private ZipTestCallable(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            final File tmp = File.createTempFile("security2455_", ".zip");
    +            tmp.deleteOnExit();
    +            toFilePathOnController(file).zip(new FilePath(tmp));
    +            return tmp.getName();
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2485")
    +    @LocalData
    +    public void unzipRemoteTest() {
    +        final File targetDir = j.jenkins.getRootDir();
    +        final File source = new File(targetDir, "file.zip"); // in this test, controller and agent are on same FS so this works -- file needs to exist but content should not be read
    +        assertTrue(targetDir.exists());
    +        final List<File> filesBefore = Arrays.asList(Objects.requireNonNull(targetDir.listFiles()));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new UnzipRemoteTestCallable(targetDir, source)));
    +        final List<File> filesAfter = Arrays.asList(Objects.requireNonNull(targetDir.listFiles()));
    +        // We cannot do a direct comparison here because `logs/` appears during execution
    +        assertEquals(filesBefore.size(), filesAfter.stream().filter(it -> !it.getName().equals("logs")).count());
    +    }
    +
    +    private static class UnzipRemoteTestCallable extends MasterToSlaveCallable<String, Exception> {
    +        private final File destination;
    +        private final File source;
    +
    +        private UnzipRemoteTestCallable(File destination, File source) {
    +            this.destination = destination;
    +            this.source = source;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            FilePath onAgent = new FilePath(source);
    +            onAgent.unzip(toFilePathOnController(destination));
    +            return null;
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2485")
    +    public void testCopyRecursiveFromControllerToAgent() {
    +        IOException ex = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CopyRecursiveToFromControllerToAgentCallable(new FilePath(new File(j.jenkins.getRootDir(), "secrets")))));
    +        assertThat(Objects.requireNonNull(ex).getMessage(), containsString("Unexpected end of ZLIB input stream")); // TODO this used to say "premature", why the change?
    +    }
    +    private static class CopyRecursiveToFromControllerToAgentCallable extends MasterToSlaveCallable<Integer, Exception> {
    +        private final FilePath controllerFilePath;
    +
    +        private CopyRecursiveToFromControllerToAgentCallable(FilePath controllerFilePath) {
    +            this.controllerFilePath = controllerFilePath;
    +        }
    +
    +        @Override
    +        public Integer call() throws Exception {
    +            return controllerFilePath.copyRecursiveTo(new FilePath(Files.createTempDirectory("jenkins-test").toFile()));
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2485")
    +    public void testCopyRecursiveFromAgentToController() {
    +        assertThrowsIOExceptionCausedBy(SecurityException.class, () -> invokeOnAgent(new CopyRecursiveToFromAgentToControllerCallable(new FilePath(new File(j.jenkins.getRootDir(), "secrets")))));
    +    }
    +    private static class CopyRecursiveToFromAgentToControllerCallable extends MasterToSlaveCallable<Integer, Exception> {
    +        private final FilePath controllerFilePath;
    +
    +        private CopyRecursiveToFromAgentToControllerCallable(FilePath controllerFilePath) {
    +            this.controllerFilePath = controllerFilePath;
    +        }
    +
    +        @Override
    +        public Integer call() throws Exception {
    +            final File localPath = Files.createTempDirectory("jenkins-test").toFile();
    +            assertTrue(new File(localPath, "tmpfile").createNewFile());
    +            return new FilePath(localPath).copyRecursiveTo(controllerFilePath);
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2486")
    +    public void testDecoyWrapper() {
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ReadToStringBypassCallable(j.jenkins.getRootDir())));
    +    }
    +
    +    private static class ReadToStringBypassCallable extends MasterToSlaveCallable<String, Exception> {
    +        private final File file;
    +
    +        private ReadToStringBypassCallable(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            final Class<?> readToStringClass = Class.forName("hudson.FilePath$ReadToString");
    +            final Constructor<?> constructor = readToStringClass.getDeclaredConstructor(); // Used to have FilePath.class from non-static context
    +            constructor.setAccessible(true);
    +
    +            //FilePath agentFilePath = new FilePath(new File("on agent lol")); // only used for the core code before fix
    +
    +            final SlaveToMasterFileCallable<?> callable = (SlaveToMasterFileCallable<?>) constructor.newInstance(); // agentFilePath
    +
    +            FilePath controllerFilePath = toFilePathOnController(new File(file, "secrets/master.key"));
    +            final Object returned = controllerFilePath.act(callable);
    +            return (String) returned;
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2531")
    +    public void testSymlinkCheck() throws Exception {
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new SymlinkCreator(buildDir)));
    +    }
    +    private static class SymlinkCreator extends MasterToSlaveCallable<String, Exception> {
    +        private final File baseDir;
    +        private SymlinkCreator(File baseDir) {
    +            this.baseDir = baseDir;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(new File(baseDir, "child")).symlinkTo(baseDir.getPath() + "child2", TaskListener.NULL);
    +            return null;
    +        }
    +    }
    +
    +    // --------
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2538") // SECURITY-2538 adjacent, confirms that reading, stating etc. is supposed to be possible for userContent
    +    public void testReadUserContent() throws Exception {
    +        invokeOnAgent(new UserContentReader(new File(j.jenkins.getRootDir(), "userContent")));
    +    }
    +    private static class UserContentReader extends MasterToSlaveCallable<String, Exception> {
    +        private final File userContent;
    +
    +        private UserContentReader(File userContent) {
    +            this.userContent = userContent;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            final FilePath userContentFilePath = toFilePathOnController(userContent);
    +            userContentFilePath.lastModified();
    +            userContentFilePath.zip(NullOutputStream.NULL_OUTPUT_STREAM);
    +            assertThat(userContentFilePath.child("readme.txt").readToString(), containsString(hudson.model.Messages.Hudson_USER_CONTENT_README()));
    +            return null;
    +        }
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2538")
    +    public void testRenameTo() throws Exception {
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        final File userContentDir = new File(j.jenkins.getRootDir(), "userContent");
    +        final File readme = new File(userContentDir, "readme.txt");
    +        final File to = new File(buildDir, "readme.txt");
    +        assertTrue("readme.txt is a file", readme.isFile());
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new RenameToCaller(readme, to)));
    +        assertTrue("readme.txt is still a file", readme.isFile());
    +        assertFalse("to does not exist", to.exists());
    +    }
    +    private static class RenameToCaller extends MasterToSlaveCallable<String, Exception> {
    +        private final File from;
    +        private final File to;
    +
    +        private RenameToCaller(File from, File to) {
    +            this.from = from;
    +            this.to = to;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(from).renameTo(toFilePathOnController(to));
    +            return null;
    +        }
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2538")
    +    public void testMoveChildren() throws Exception {
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        final File userContentDir = new File(j.jenkins.getRootDir(), "userContent");
    +        // The implementation of MoveAllChildrenTo seems odd and ends up removing the source directory, so work only in subdir of userContent
    +        final File userContentSubDir = new File(userContentDir, "stuff");
    +        assertTrue(userContentSubDir.mkdirs());
    +        final File userContentSubDirFileA = new File(userContentSubDir, "fileA");
    +        final File userContentSubDirFileB = new File(userContentSubDir, "fileB");
    +        assertTrue(userContentSubDirFileA.createNewFile());
    +        assertTrue(userContentSubDirFileB.createNewFile());
    +        assertTrue("userContentSubDir is a directory", userContentSubDir.isDirectory());
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new MoveAllChildrenToCaller(userContentSubDir, buildDir)));
    +        assertTrue("userContentSubDir is a directory", userContentSubDir.isDirectory());
    +        assertFalse("no fileA in buildDir", new File(buildDir, "fileA").exists());
    +        assertFalse("no fileB in buildDir", new File(buildDir, "fileB").exists());
    +        assertTrue("fileA is still a file", userContentSubDirFileA.isFile());
    +        assertTrue("fileB is still a file", userContentSubDirFileB.isFile());
    +    }
    +    private static class MoveAllChildrenToCaller extends MasterToSlaveCallable<String, Exception> {
    +        private final File from;
    +        private final File to;
    +
    +        private MoveAllChildrenToCaller(File from, File to) {
    +            this.from = from;
    +            this.to = to;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(from).moveAllChildrenTo(toFilePathOnController(to));
    +            return null;
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2539")
    +    public void testCreateTempFile() {
    +        final File rootDir = j.jenkins.getRootDir();
    +        assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix")));
    +
    +        // Extra level of catch/throw
    +        logging.capture(10);
    +        final IOException ioException = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CreateTempFileCaller(rootDir)));
    +        assertNotNull(ioException);
    +        final Throwable cause = ioException.getCause();
    +        assertNotNull(cause);
    +        assertTrue(cause instanceof SecurityException);
    +        assertThat(cause.getMessage(), not(containsString("prefix"))); // redacted
    +        assertThat(logging, recorded(containsString("'create'")));
    +        assertThat(logging, recorded(containsString("/prefix-security-check-dummy-suffix")));
    +        assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix")));
    +    }
    +    private static class CreateTempFileCaller extends MasterToSlaveCallable<String, Exception> {
    +        private final File baseDir;
    +
    +        private CreateTempFileCaller(File baseDir) {
    +            this.baseDir = baseDir;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(baseDir).createTempFile("prefix", "suffix");
    +            return null;
    +        }
    +    }
    +
    +
    +    @Test
    +    @Issue("SECURITY-2539")
    +    public void testCreateTextTempFile() {
    +        final File rootDir = j.jenkins.getRootDir();
    +        assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix")));
    +
    +        // Extra level of catch/throw
    +        logging.capture(10);
    +        final IOException ioException = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CreateTextTempFileCaller(rootDir)));
    +        assertNotNull(ioException);
    +        final Throwable cause = ioException.getCause();
    +        assertNotNull(cause);
    +        assertTrue(cause instanceof SecurityException);
    +        assertThat(cause.getMessage(), not(containsString("prefix"))); // redacted
    +        assertThat(logging, recorded(containsString("'create'")));
    +        assertThat(logging, recorded(containsString("/prefix-security-check-dummy-suffix")));
    +        assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix")));
    +    }
    +    private static class CreateTextTempFileCaller extends MasterToSlaveCallable<String, Exception> {
    +        private final File baseDir;
    +
    +        private CreateTextTempFileCaller(File baseDir) {
    +            this.baseDir = baseDir;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(baseDir).createTextTempFile("prefix", "suffix", "content");
    +            return null;
    +        }
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2539")
    +    public void testCreateTempDir() {
    +        final File rootDir = j.jenkins.getRootDir();
    +        assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix")));
    +
    +        // Extra level of catch/throw
    +        logging.capture(10);
    +        final IOException ioException = assertThrowsIOExceptionCausedBy(IOException.class, () -> invokeOnAgent(new CreateTempDirCaller(rootDir)));
    +        assertNotNull(ioException);
    +        final Throwable cause = ioException.getCause();
    +        assertNotNull(cause);
    +        assertTrue(cause instanceof SecurityException);
    +        assertThat(cause.getMessage(), not(containsString("prefix"))); // redacted
    +        assertThat(logging, recorded(containsString("'mkdirs'")));
    +        assertThat(logging, recorded(containsString("/prefix.suffix-security-test"))); // weird but that's what it looks like
    +        assertFalse(Arrays.stream(Objects.requireNonNull(rootDir.listFiles())).anyMatch(it -> it.getName().contains("prefix")));
    +    }
    +    private static class CreateTempDirCaller extends MasterToSlaveCallable<String, Exception> {
    +        private final File baseDir;
    +
    +        private CreateTempDirCaller(File baseDir) {
    +            this.baseDir = baseDir;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            toFilePathOnController(baseDir).createTempDir("prefix", "suffix");
    +            return null;
    +        }
    +    }
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2541")
    +    public void testStatStuff() {
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new ToURICaller(j.jenkins.getRootDir())));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new FreeDiskSpaceCaller(j.jenkins.getRootDir())));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new TotalDiskSpaceCaller(j.jenkins.getRootDir())));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new UsableDiskSpaceCaller(j.jenkins.getRootDir())));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new AbsolutizeCaller(j.jenkins.getRootDir())));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new HasSymlinkCaller(new File (j.jenkins.getRootDir(), "secrets"), j.jenkins.getRootDir())));
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new IsDescendantCaller(j.jenkins.getRootDir(), "secrets")));
    +    }
    +
    +    private static class ToURICaller extends MasterToSlaveCallable<URI, Exception> {
    +        private final File file;
    +
    +        private ToURICaller(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public URI call() throws Exception {
    +            return toFilePathOnController(file).toURI();
    +        }
    +    }
    +    private static class AbsolutizeCaller extends MasterToSlaveCallable<String, Exception> {
    +        private final File file;
    +
    +        private AbsolutizeCaller(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public String call() throws Exception {
    +            return toFilePathOnController(file).absolutize().getRemote();
    +        }
    +    }
    +    private static class HasSymlinkCaller extends MasterToSlaveCallable<Boolean, Exception> {
    +        private final File file;
    +        private final File root;
    +
    +        private HasSymlinkCaller(File file, File root) {
    +            this.file = file;
    +            this.root = root;
    +        }
    +
    +        @Override
    +        public Boolean call() throws Exception {
    +            return toFilePathOnController(file).hasSymlink(toFilePathOnController(root), false);
    +        }
    +    }
    +    private static class IsDescendantCaller extends MasterToSlaveCallable<Boolean, Exception> {
    +        private final File file;
    +        private final String childPath;
    +
    +        private IsDescendantCaller(File file, String childPath) {
    +            this.file = file;
    +            this.childPath = childPath;
    +        }
    +
    +        @Override
    +        public Boolean call() throws Exception {
    +            return toFilePathOnController(file).isDescendant(childPath);
    +        }
    +    }
    +    private static class FreeDiskSpaceCaller extends MasterToSlaveCallable<Long, Exception> {
    +        private final File file;
    +
    +        private FreeDiskSpaceCaller(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public Long call() throws Exception {
    +            return toFilePathOnController(file).getFreeDiskSpace();
    +        }
    +    }
    +    private static class UsableDiskSpaceCaller extends MasterToSlaveCallable<Long, Exception> {
    +        private final File file;
    +
    +        private UsableDiskSpaceCaller(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public Long call() throws Exception {
    +            return toFilePathOnController(file).getUsableDiskSpace();
    +        }
    +    }
    +    private static class TotalDiskSpaceCaller extends MasterToSlaveCallable<Long, Exception> {
    +        private final File file;
    +
    +        private TotalDiskSpaceCaller(File file) {
    +            this.file = file;
    +        }
    +
    +        @Override
    +        public Long call() throws Exception {
    +            return toFilePathOnController(file).getTotalDiskSpace();
    +        }
    +    }
    +
    +    // --------
    +
    +    @Test
    +    @Issue("SECURITY-2542") // adjacent, this confirms we follow symlinks when it's within allowed directories
    +    public void testGlobFollowsSymlinks() throws Exception {
    +        assumeFalse(Functions.isWindows());
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        // We cannot touch the build dir itself
    +        final File innerDir = new File(buildDir, "dir");
    +        final File innerDir2 = new File(buildDir, "dir2");
    +        assertTrue(innerDir.mkdirs());
    +        assertTrue(innerDir2.mkdirs());
    +        assertTrue(new File(innerDir2, "the-file").createNewFile());
    +        Util.createSymlink(innerDir, "../dir2", "link", TaskListener.NULL);
    +        assertTrue(new File(innerDir, "link/the-file").exists());
    +        final int files = invokeOnAgent(new GlobCaller(innerDir));
    +        assertEquals(1, files);
    +    }
    +    @Test
    +    @Issue("SECURITY-2542")
    +    public void testGlobSymlinksThrowsOutsideAllowedDirectories() throws Exception {
    +        assumeFalse(Functions.isWindows());
    +        final File buildDir = j.buildAndAssertSuccess(j.createFreeStyleProject()).getRootDir();
    +        // We cannot touch the build dir itself
    +        final File innerDir = new File(buildDir, "dir");
    +        assertTrue(innerDir.mkdirs());
    +        Util.createSymlink(innerDir, "../../../../../secrets", "secrets-link", TaskListener.NULL);
    +        assertTrue(new File(innerDir, "secrets-link/master.key").exists());
    +        assertThrowsIOExceptionCausedBySecurityException(() -> invokeOnAgent(new GlobCaller(innerDir)));
    +    }
    +    private static class GlobCaller extends MasterToSlaveCallable<Integer, Exception> {
    +        private final File root;
    +
    +        private GlobCaller(File root) {
    +            this.root = root;
    +        }
    +
    +        @Override
    +        public Integer call() throws Exception {
    +            return toFilePathOnController(root).list("**/*", "", false).length;
    +        }
    +    }
    +
    +    // --------
    +
    +    // Utility functions
    +
    +    protected static FilePath toFilePathOnController(File file) {
    +        return toFilePathOnController(file.getPath());
    +    }
    +
    +    protected static FilePath toFilePathOnController(String path) {
    +        final VirtualChannel channel = AgentComputerUtil.getChannelToMaster();
    +        return new FilePath(channel, path);
    +    }
    +
    +    protected  <T, X extends Throwable> T invokeOnAgent(MasterToSlaveCallable<T, X> callable) throws Exception, X {
    +        final Node agent = j.createOnlineSlave();
    +        return Objects.requireNonNull(agent.getChannel()).call(callable);
    +    }
    +
    +    private static SecurityException assertThrowsIOExceptionCausedBySecurityException(ThrowingRunnable runnable) {
    +        return assertThrowsIOExceptionCausedBy(SecurityException.class, runnable);
    +    }
    +
    +    private static <X extends Throwable> X assertThrowsIOExceptionCausedBy(Class<X> causeClass, ThrowingRunnable runnable) {
    +        try {
    +            runnable.run();
    +        } catch (IOException ex) {
    +            final Throwable cause = ex.getCause();
    +            assertTrue("IOException with message: '" + ex.getMessage() + "' wasn't caused by " + causeClass + ": " + (cause == null ? "(null)" : (cause.getClass().getName() + ": " + cause.getMessage())),
    +                    cause != null && causeClass.isAssignableFrom(cause.getClass()));
    +            return causeClass.cast(cause);
    +        } catch (Throwable t) {
    +            fail("Threw other Throwable: " + t.getClass() + " with message " + t.getMessage());
    +        }
    +        fail("Expected exception but passed");
    +        return null;
    +    }
    +}
    
  • test/src/test/resources/jenkins/security/Security2455Test/symlink.tar+0 0 added
  • test/src/test/resources/jenkins/security/Security2455Test/unzipRemoteTest/file.zip+0 0 added

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

1