apache-sshd/CVE-2023-35887.patch
starlet-dx e7c7e18533 Fix CVE-2023-35887
(cherry picked from commit 91fac8710abf01ff4259485c5b5b061a9b5d05e9)
2024-01-11 19:18:26 +08:00

2339 lines
102 KiB
Diff

From c20739b43aab0f7bf2ccad982a6cb37b9d5a8a0b Mon Sep 17 00:00:00 2001
From: Guillaume Nodet <gnodet@gmail.com>
Date: Tue, 9 May 2023 15:17:26 +0200
Subject: [PATCH] [SSHD-1234] Rooted file system can leak informations
---
.../file/root/RootedDirectoryStream.java | 63 +++
.../common/file/root/RootedFileSystem.java | 5 +
.../file/root/RootedFileSystemProvider.java | 327 ++++++++----
.../file/root/RootedFileSystemUtils.java | 55 ++
.../root/RootedSecureDirectoryStream.java | 92 ++++
.../sshd/common/file/util/BaseFileSystem.java | 23 +-
.../apache/sshd/common/util/io/IoUtils.java | 160 +++++-
.../root/RootedFileSystemProviderTest.java | 469 ++++++++++++++----
.../sshd/common/util/io/IoUtilsTest.java | 48 ++
.../util/test/CommonTestSupportUtils.java | 4 +-
.../client/fs/SftpFileSystemProvider.java | 4 +-
.../server/AbstractSftpSubsystemHelper.java | 101 +++-
.../sftp/server/SftpFileSystemAccessor.java | 94 +++-
.../sshd/sftp/server/SftpSubsystem.java | 8 +-
.../org/apache/sshd/sftp/client/SftpTest.java | 7 +-
.../sshd/sftp/client/SftpVersionsTest.java | 12 +-
16 files changed, 1203 insertions(+), 269 deletions(-)
create mode 100644 sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedDirectoryStream.java
create mode 100644 sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemUtils.java
create mode 100644 sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedSecureDirectoryStream.java
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedDirectoryStream.java b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedDirectoryStream.java
new file mode 100644
index 000000000..dde9e748d
--- /dev/null
+++ b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedDirectoryStream.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sshd.common.file.root;
+
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Path;
+import java.util.Iterator;
+
+/**
+ * secure directory stream proxy for a {@link RootedFileSystem}
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class RootedDirectoryStream implements DirectoryStream<Path> {
+ protected final RootedFileSystem rfs;
+ protected final DirectoryStream<Path> delegate;
+
+ public RootedDirectoryStream(RootedFileSystem rfs, DirectoryStream<Path> delegate) {
+ this.rfs = rfs;
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Iterator<Path> iterator() {
+ return root(rfs, delegate.iterator());
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+
+ protected Iterator<Path> root(RootedFileSystem rfs, Iterator<Path> iter) {
+ return new Iterator<Path>() {
+ @Override
+ public boolean hasNext() {
+ return iter.hasNext();
+ }
+
+ @Override
+ public Path next() {
+ return rfs.provider().root(rfs, iter.next());
+ }
+ };
+ }
+}
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystem.java b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystem.java
index 1b9a71ca1..efd0b134d 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystem.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystem.java
@@ -94,6 +94,11 @@ public Iterable<FileStore> getFileStores() {
return rootFs.getFileStores();
}
+ @Override
+ protected boolean hostFsHasWindowsSeparator() {
+ return "\\".equals(getRoot().getFileSystem().getSeparator());
+ }
+
@Override
public String toString() {
return rootPath.toString();
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemProvider.java b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemProvider.java
index 0855c7751..9922582b1 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemProvider.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemProvider.java
@@ -18,6 +18,7 @@
*/
package org.apache.sshd.common.file.root;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -26,32 +27,40 @@
import java.nio.channels.AsynchronousFileChannel;
import java.nio.channels.FileChannel;
import java.nio.channels.SeekableByteChannel;
+import java.nio.file.AccessDeniedException;
import java.nio.file.AccessMode;
+import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.CopyOption;
+import java.nio.file.DirectoryNotEmptyException;
import java.nio.file.DirectoryStream;
+import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileStore;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemAlreadyExistsException;
+import java.nio.file.FileSystemException;
+import java.nio.file.FileSystemLoopException;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.NotLinkException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.ProviderMismatchException;
+import java.nio.file.SecureDirectoryStream;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.FileAttributeView;
import java.nio.file.spi.FileSystemProvider;
import java.util.HashMap;
-import java.util.Iterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutorService;
-import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.io.IoUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -157,14 +166,22 @@ public Path getPath(URI uri) {
public InputStream newInputStream(Path path, OpenOption... options) throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- return p.newInputStream(r, options);
+ try {
+ return p.newInputStream(r, options);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
public OutputStream newOutputStream(Path path, OpenOption... options) throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- return p.newOutputStream(r, options);
+ try {
+ return p.newOutputStream(r, options);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
@@ -172,7 +189,11 @@ public FileChannel newFileChannel(Path path, Set<? extends OpenOption> options,
throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- return p.newFileChannel(r, options, attrs);
+ try {
+ return p.newFileChannel(r, options, attrs);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
@@ -181,7 +202,11 @@ public AsynchronousFileChannel newAsynchronousFileChannel(
throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- return p.newAsynchronousFileChannel(r, options, executor, attrs);
+ try {
+ return p.newAsynchronousFileChannel(r, options, executor, attrs);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
@@ -189,82 +214,74 @@ public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> o
throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- return p.newByteChannel(r, options, attrs);
+ try {
+ return p.newByteChannel(r, options, attrs);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
public DirectoryStream<Path> newDirectoryStream(Path dir, DirectoryStream.Filter<? super Path> filter) throws IOException {
Path r = unroot(dir);
FileSystemProvider p = provider(r);
- return root(((RootedPath) dir).getFileSystem(), p.newDirectoryStream(r, filter));
+ try {
+ return root(((RootedPath) dir).getFileSystem(), p.newDirectoryStream(r, filter));
+ } catch (IOException ex) {
+ throw translateIoException(ex, dir);
+ }
}
protected DirectoryStream<Path> root(RootedFileSystem rfs, DirectoryStream<Path> ds) {
- return new DirectoryStream<Path>() {
- @Override
- public Iterator<Path> iterator() {
- return root(rfs, ds.iterator());
- }
-
- @Override
- public void close() throws IOException {
- ds.close();
- }
- };
- }
-
- protected Iterator<Path> root(RootedFileSystem rfs, Iterator<Path> iter) {
- return new Iterator<Path>() {
- @Override
- public boolean hasNext() {
- return iter.hasNext();
- }
-
- @Override
- public Path next() {
- return root(rfs, iter.next());
- }
- };
+ if (ds instanceof SecureDirectoryStream) {
+ return new RootedSecureDirectoryStream(rfs, (SecureDirectoryStream<Path>) ds);
+ }
+ return new RootedDirectoryStream(rfs, ds);
}
@Override
public void createDirectory(Path dir, FileAttribute<?>... attrs) throws IOException {
Path r = unroot(dir);
FileSystemProvider p = provider(r);
- p.createDirectory(r, attrs);
+ try {
+ p.createDirectory(r, attrs);
+ } catch (IOException ex) {
+ throw translateIoException(ex, dir);
+ }
}
@Override
public void createSymbolicLink(Path link, Path target, FileAttribute<?>... attrs) throws IOException {
- createLink(link, target, true, attrs);
- }
-
- @Override
- public void createLink(Path link, Path existing) throws IOException {
- createLink(link, existing, false);
- }
-
- protected void createLink(Path link, Path target, boolean symLink, FileAttribute<?>... attrs) throws IOException {
+ // make sure symlink cannot break out of chroot jail. If it is unsafe, simply thrown an exception. This is
+ // to ensure that symlink semantics are maintained when it is safe, and creation fails when not.
+ RootedFileSystemUtils.validateSafeRelativeSymlink(target);
Path l = unroot(link);
- Path t = unroot(target);
- /*
- * For a symbolic link preserve the relative path
- */
- if (symLink && (!target.isAbsolute())) {
- RootedFileSystem rfs = ((RootedPath) target).getFileSystem();
- Path root = rfs.getRoot();
- t = root.relativize(t);
- }
+ Path t = target.isAbsolute() ? unroot(target) : l.getFileSystem().getPath(target.toString());
FileSystemProvider p = provider(l);
- if (symLink) {
+ try {
p.createSymbolicLink(l, t, attrs);
- } else {
- p.createLink(l, t);
+
+ if (log.isDebugEnabled()) {
+ log.debug("createSymbolicLink({} => {}", l, t);
+ }
+ } catch (IOException ex) {
+ throw translateIoException(ex, link);
}
+ }
- if (log.isDebugEnabled()) {
- log.debug("createLink(symbolic={}) {} => {}", symLink, l, t);
+ @Override
+ public void createLink(Path link, Path existing) throws IOException {
+ Path l = unroot(link);
+ Path t = unroot(existing);
+
+ try {
+ provider(l).createLink(l, t);
+ if (log.isDebugEnabled()) {
+ log.debug("createLink({} => {}", l, t);
+ }
+ } catch (IOException ex) {
+ throw translateIoException(ex, link);
}
}
@@ -275,7 +292,11 @@ public void delete(Path path) throws IOException {
log.trace("delete({}): {}", path, r);
}
FileSystemProvider p = provider(r);
- p.delete(r);
+ try {
+ p.delete(r);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
@@ -285,19 +306,27 @@ public boolean deleteIfExists(Path path) throws IOException {
log.trace("deleteIfExists({}): {}", path, r);
}
FileSystemProvider p = provider(r);
- return p.deleteIfExists(r);
+ try {
+ return p.deleteIfExists(r);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
public Path readSymbolicLink(Path link) throws IOException {
Path r = unroot(link);
FileSystemProvider p = provider(r);
- Path t = p.readSymbolicLink(r);
- Path target = root((RootedFileSystem) link.getFileSystem(), t);
- if (log.isTraceEnabled()) {
- log.trace("readSymbolicLink({})[{}]: {}[{}]", link, r, target, t);
+ try {
+ Path t = p.readSymbolicLink(r);
+ Path target = root((RootedFileSystem) link.getFileSystem(), t);
+ if (log.isTraceEnabled()) {
+ log.trace("readSymbolicLink({})[{}]: {}[{}]", link, r, target, t);
+ }
+ return target;
+ } catch (IOException ex) {
+ throw translateIoException(ex, link);
}
- return target;
}
@Override
@@ -308,7 +337,11 @@ public void copy(Path source, Path target, CopyOption... options) throws IOExcep
log.trace("copy({})[{}]: {}[{}]", source, s, target, t);
}
FileSystemProvider p = provider(s);
- p.copy(s, t, options);
+ try {
+ p.copy(s, t, options);
+ } catch (IOException ex) {
+ throw translateIoException(ex, source);
+ }
}
@Override
@@ -319,7 +352,11 @@ public void move(Path source, Path target, CopyOption... options) throws IOExcep
log.trace("move({})[{}]: {}[{}]", source, s, target, t);
}
FileSystemProvider p = provider(s);
- p.move(s, t, options);
+ try {
+ p.move(s, t, options);
+ } catch (IOException ex) {
+ throw translateIoException(ex, source);
+ }
}
@Override
@@ -327,21 +364,33 @@ public boolean isSameFile(Path path, Path path2) throws IOException {
Path r = unroot(path);
Path r2 = unroot(path2);
FileSystemProvider p = provider(r);
- return p.isSameFile(r, r2);
+ try {
+ return p.isSameFile(r, r2);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
public boolean isHidden(Path path) throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- return p.isHidden(r);
+ try {
+ return p.isHidden(r);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
public FileStore getFileStore(Path path) throws IOException {
RootedFileSystem fileSystem = getFileSystem(path);
Path root = fileSystem.getRoot();
- return Files.getFileStore(root);
+ try {
+ return Files.getFileStore(root);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
protected RootedFileSystem getFileSystem(Path path) throws FileSystemNotFoundException {
@@ -384,7 +433,11 @@ protected RootedFileSystem getFileSystem(Path path) throws FileSystemNotFoundExc
public void checkAccess(Path path, AccessMode... modes) throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- p.checkAccess(r, modes);
+ try {
+ p.checkAccess(r, modes);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
@@ -403,18 +456,26 @@ public <A extends BasicFileAttributes> A readAttributes(Path path, Class<A> type
}
FileSystemProvider p = provider(r);
- return p.readAttributes(r, type, options);
+ try {
+ return p.readAttributes(r, type, options);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
@Override
public Map<String, Object> readAttributes(Path path, String attributes, LinkOption... options) throws IOException {
Path r = unroot(path);
FileSystemProvider p = provider(r);
- Map<String, Object> attrs = p.readAttributes(r, attributes, options);
- if (log.isTraceEnabled()) {
- log.trace("readAttributes({})[{}] {}: {}", path, r, attributes, attrs);
+ try {
+ Map<String, Object> attrs = p.readAttributes(r, attributes, options);
+ if (log.isTraceEnabled()) {
+ log.trace("readAttributes({})[{}] {}: {}", path, r, attributes, attrs);
+ }
+ return attrs;
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
}
- return attrs;
}
@Override
@@ -424,7 +485,11 @@ public void setAttribute(Path path, String attribute, Object value, LinkOption..
log.trace("setAttribute({})[{}] {}={}", path, r, attribute, value);
}
FileSystemProvider p = provider(r);
- p.setAttribute(r, attribute, value, options);
+ try {
+ p.setAttribute(r, attribute, value, options);
+ } catch (IOException ex) {
+ throw translateIoException(ex, path);
+ }
}
protected FileSystemProvider provider(Path path) {
@@ -434,10 +499,33 @@ protected FileSystemProvider provider(Path path) {
protected Path root(RootedFileSystem rfs, Path nat) {
if (nat.isAbsolute()) {
+ // preferred case - this isn't a symlink out of our jail
+ if (nat.startsWith(rfs.getRoot())) {
+ // If we have the same number of parts as the root, and start with the root, we must be the root.
+ if (nat.getNameCount() == rfs.getRoot().getNameCount()) {
+ return rfs.getPath("/");
+ }
+
+ // We are the root, and more. Get the first name past the root because of how getPath works
+ String firstName = "/" + nat.getName(rfs.getRoot().getNameCount());
+
+ // the rooted path should have the number of parts past the root
+ String[] varargs = new String[nat.getNameCount() - rfs.getRoot().getNameCount() - 1];
+ int varargsCounter = 0;
+ for (int i = 1 + rfs.getRoot().getNameCount(); i < nat.getNameCount(); i++) {
+ varargs[varargsCounter++] = nat.getName(i).toString();
+ }
+ return rfs.getPath(firstName, varargs);
+ }
+
+ // This is the case where there's a symlink jailbreak, so we return a relative link as the directories above
+ // the chroot don't make sense to present
+ // The behavior with the fs class is that we follow the symlink. Note that this is dangerous.
Path root = rfs.getRoot();
Path rel = root.relativize(nat);
- return rfs.getPath("/" + rel.toString());
+ return rfs.getPath("/" + rel);
} else {
+ // For a relative symlink, simply return it as a RootedPath. Note that this may break out of the chroot.
return rfs.getPath(nat.toString());
}
}
@@ -466,39 +554,74 @@ protected Path unroot(Path path) {
* @throws InvalidPathException If the resolved path is not a proper sub-path of the rooted file system
*/
protected Path resolveLocalPath(RootedPath path) {
- RootedPath absPath = Objects.requireNonNull(path, "No rooted path to resolve").toAbsolutePath();
- RootedFileSystem rfs = absPath.getFileSystem();
+ Objects.requireNonNull(path, "No rooted path to resolve");
+ RootedFileSystem rfs = path.getFileSystem();
Path root = rfs.getRoot();
- FileSystem lfs = root.getFileSystem();
+ // initialize a list for the new file name parts
+ Path resolved = IoUtils.chroot(root, path);
- String rSep = ValidateUtils.checkNotNullAndNotEmpty(rfs.getSeparator(), "No rooted file system separator");
- ValidateUtils.checkTrue(rSep.length() == 1, "Bad rooted file system separator: %s", rSep);
- char rootedSeparator = rSep.charAt(0);
+ /*
+ * This can happen for Windows since we represent its paths as /C:/some/path, so substring(1) yields
+ * C:/some/path - which is resolved as an absolute path (which we don't want).
+ *
+ * This also is a security assertion to protect against unknown attempts to break out of the chroot jail
+ */
+ if (!resolved.normalize().startsWith(root)) {
+ throw new InvalidPathException(root.toString(), "Not under root");
+ }
+ return resolved;
+ }
- String lSep = ValidateUtils.checkNotNullAndNotEmpty(lfs.getSeparator(), "No local file system separator");
- ValidateUtils.checkTrue(lSep.length() == 1, "Bad local file system separator: %s", lSep);
- char localSeparator = lSep.charAt(0);
+ private IOException translateIoException(IOException ex, Path rootedPath) {
+ // cast is safe as path was unrooted earlier.
+ RootedPath rootedPathCasted = (RootedPath) rootedPath;
+ Path root = rootedPathCasted.getFileSystem().getRoot();
+
+ if (ex instanceof FileSystemException) {
+ String file = fixExceptionFileName(root, rootedPath, ((FileSystemException) ex).getFile());
+ String otherFile = fixExceptionFileName(root, rootedPath, ((FileSystemException) ex).getOtherFile());
+ String reason = ((FileSystemException) ex).getReason();
+ if (NoSuchFileException.class.equals(ex.getClass())) {
+ return new NoSuchFileException(file, otherFile, reason);
+ } else if (FileSystemLoopException.class.equals(ex.getClass())) {
+ return new FileSystemLoopException(file);
+ } else if (NotDirectoryException.class.equals(ex.getClass())) {
+ return new NotDirectoryException(file);
+ } else if (DirectoryNotEmptyException.class.equals(ex.getClass())) {
+ return new DirectoryNotEmptyException(file);
+ } else if (NotLinkException.class.equals(ex.getClass())) {
+ return new NotLinkException(file);
+ } else if (AtomicMoveNotSupportedException.class.equals(ex.getClass())) {
+ return new AtomicMoveNotSupportedException(file, otherFile, reason);
+ } else if (FileAlreadyExistsException.class.equals(ex.getClass())) {
+ return new FileAlreadyExistsException(file, otherFile, reason);
+ } else if (AccessDeniedException.class.equals(ex.getClass())) {
+ return new AccessDeniedException(file, otherFile, reason);
+ }
+ return new FileSystemException(file, otherFile, reason);
+ } else if (ex.getClass().equals(FileNotFoundException.class)) {
+ return new FileNotFoundException(ex.getLocalizedMessage().replace(root.toString(), ""));
+ }
+ // not sure how to translate, so leave as is. Hopefully does not leak data
+ return ex;
+ }
- String r = absPath.toString();
- String subPath = r.substring(1);
- if (rootedSeparator != localSeparator) {
- subPath = subPath.replace(rootedSeparator, localSeparator);
+ private String fixExceptionFileName(Path root, Path rootedPath, String fileName) {
+ if (fileName == null) {
+ return null;
}
- Path resolved = root.resolve(subPath);
- resolved = resolved.normalize();
- resolved = resolved.toAbsolutePath();
- if (log.isTraceEnabled()) {
- log.trace("resolveLocalPath({}): {}", absPath, resolved);
+ Path toFix = root.getFileSystem().getPath(fileName);
+ if (toFix.getNameCount() == root.getNameCount()) {
+ // return the root
+ return rootedPath.getFileSystem().getSeparator();
}
- /*
- * This can happen for Windows since we represent its paths as /C:/some/path, so substring(1) yields
- * C:/some/path - which is resolved as an absolute path (which we don't want).
- */
- if (!resolved.startsWith(root)) {
- throw new InvalidPathException(r, "Not under root");
+ StringBuilder ret = new StringBuilder();
+ for (int partNum = root.getNameCount(); partNum < toFix.getNameCount(); partNum++) {
+ ret.append(rootedPath.getFileSystem().getSeparator());
+ ret.append(toFix.getName(partNum++));
}
- return resolved;
+ return ret.toString();
}
}
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemUtils.java b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemUtils.java
new file mode 100644
index 000000000..6b0b164e9
--- /dev/null
+++ b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedFileSystemUtils.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sshd.common.file.root;
+
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+
+/**
+ * Utility functions for rooted file utils
+ */
+public final class RootedFileSystemUtils {
+
+ private RootedFileSystemUtils() {
+ // do not construct
+ }
+
+ /**
+ * Validate that the relative path target is safe. This means that at no point in the path can there be more ".."
+ * than path parts.
+ *
+ * @param target the target directory to validate is safe.
+ */
+ public static void validateSafeRelativeSymlink(Path target) {
+ int numNames = 0;
+ int numCdUps = 0;
+ for (int i = 0; i < target.getNameCount(); i++) {
+ if ("..".equals(target.getName(i).toString())) {
+ numCdUps++;
+ } else if (!".".equals(target.getName(i).toString())) {
+ numNames++;
+ }
+
+ // need to check at each part to prevent data leakage outside of chroot
+ if (numCdUps > numNames) {
+ throw new InvalidPathException(target.toString(), "Symlink would exit chroot: " + target);
+ }
+ }
+ }
+}
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedSecureDirectoryStream.java b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedSecureDirectoryStream.java
new file mode 100644
index 000000000..6fe811c5b
--- /dev/null
+++ b/sshd-common/src/main/java/org/apache/sshd/common/file/root/RootedSecureDirectoryStream.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sshd.common.file.root;
+
+import java.io.IOException;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.SecureDirectoryStream;
+import java.nio.file.attribute.FileAttribute;
+import java.nio.file.attribute.FileAttributeView;
+import java.util.Set;
+
+/**
+ * A secure directory stream proxy for a {@link RootedFileSystem}
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class RootedSecureDirectoryStream extends RootedDirectoryStream implements SecureDirectoryStream<Path> {
+
+ public RootedSecureDirectoryStream(RootedFileSystem rfs, SecureDirectoryStream<Path> delegate) {
+ super(rfs, delegate);
+ }
+
+ @Override
+ public SecureDirectoryStream<Path> newDirectoryStream(Path path, LinkOption... options) throws IOException {
+ return new RootedSecureDirectoryStream(rfs, delegate().newDirectoryStream(fixPath(path), options));
+ }
+
+ protected Path fixPath(Path p) {
+ if (p.isAbsolute()) {
+ return rfs.provider().unroot(p);
+ }
+
+ // convert to root fs path.
+ // Note: this IS able to go below the root directory by design - a way to break out of chroot.
+ // Be very cautious using this.
+ return rfs.getRootFileSystem().getPath(p.toString());
+ }
+
+ @Override
+ public SeekableByteChannel newByteChannel(Path path, Set<? extends OpenOption> options, FileAttribute<?>... attrs)
+ throws IOException {
+ return delegate().newByteChannel(fixPath(path), options, attrs);
+ }
+
+ @Override
+ public void deleteFile(Path path) throws IOException {
+ delegate().deleteFile(fixPath(path));
+ }
+
+ @Override
+ public void deleteDirectory(Path path) throws IOException {
+ delegate().deleteDirectory(fixPath(path));
+ }
+
+ @Override
+ public void move(Path srcpath, SecureDirectoryStream<Path> targetdir, Path targetpath) throws IOException {
+ delegate().move(fixPath(srcpath), targetdir, targetpath);
+ }
+
+ @Override
+ public <V extends FileAttributeView> V getFileAttributeView(Class<V> type) {
+ return delegate().getFileAttributeView(type);
+ }
+
+ @Override
+ public <V extends FileAttributeView> V getFileAttributeView(Path path, Class<V> type, LinkOption... options) {
+ return delegate().getFileAttributeView(path, type, options);
+ }
+
+ private SecureDirectoryStream<Path> delegate() {
+ return (SecureDirectoryStream<Path>) delegate;
+ }
+}
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/file/util/BaseFileSystem.java b/sshd-common/src/main/java/org/apache/sshd/common/file/util/BaseFileSystem.java
index a5d099e8c..51b9a03bc 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/file/util/BaseFileSystem.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/file/util/BaseFileSystem.java
@@ -34,6 +34,7 @@
import java.util.regex.Pattern;
import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.OsUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -79,7 +80,7 @@ public Iterable<FileStore> getFileStores() {
public T getPath(String first, String... more) {
StringBuilder sb = new StringBuilder();
if (!GenericUtils.isEmpty(first)) {
- appendDedupSep(sb, first.replace('\\', '/')); // in case we are running on Windows
+ appendDedupSep(sb, handleWindowsSeparator(first));
}
if (GenericUtils.length(more) > 0) {
@@ -87,8 +88,7 @@ public T getPath(String first, String... more) {
if ((sb.length() > 0) && (sb.charAt(sb.length() - 1) != '/')) {
sb.append('/');
}
- // in case we are running on Windows
- appendDedupSep(sb, segment.replace('\\', '/'));
+ appendDedupSep(sb, handleWindowsSeparator(segment));
}
}
@@ -121,6 +121,23 @@ protected void appendDedupSep(StringBuilder sb, CharSequence s) {
}
}
+ /**
+ * In case we are running on Windows, accept "\\" as a file separator. Ignore in *nix as "\\" is a valid filename
+ *
+ * @param name the name to fix the separator for if running on Windows
+ * @return the fixed name
+ */
+ protected String handleWindowsSeparator(String name) {
+ if (hostFsHasWindowsSeparator()) {
+ return name.replace('\\', '/');
+ }
+ return name;
+ }
+
+ protected boolean hostFsHasWindowsSeparator() {
+ return OsUtils.isWin32();
+ }
+
@Override
public PathMatcher getPathMatcher(String syntaxAndPattern) {
int colonIndex = Objects.requireNonNull(syntaxAndPattern, "No argument").indexOf(':');
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/util/io/IoUtils.java b/sshd-common/src/main/java/org/apache/sshd/common/util/io/IoUtils.java
index ccac66938..11dd1d92d 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/util/io/IoUtils.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/util/io/IoUtils.java
@@ -47,6 +47,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
+import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -394,18 +395,13 @@ public static String getFileOwner(Path path, LinkOption... options) throws IOExc
* explained above
*/
public static Boolean checkFileExists(Path path, LinkOption... options) {
- boolean followLinks = true;
- for (LinkOption opt : options) {
- if (opt == LinkOption.NOFOLLOW_LINKS) {
- followLinks = false;
- break;
- }
- }
+ boolean followLinks = followLinks(options);
+
try {
if (followLinks) {
path.getFileSystem().provider().checkAccess(path);
} else {
- Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
+ Files.readAttributes(path, BasicFileAttributes.class, options);
}
return Boolean.TRUE;
} catch (NoSuchFileException e) {
@@ -415,6 +411,63 @@ public static Boolean checkFileExists(Path path, LinkOption... options) {
}
}
+ /**
+ * Checks that a file exists with or without following any symlinks.
+ *
+ * @param path the path to check
+ * @param neverFollowSymlinks whether to follow symlinks
+ * @return true if the file exists with the symlink semantics, false if it doesn't exist, null
+ * if symlinks were found, or it is unknown if whether the file exists
+ */
+ public static Boolean checkFileExistsAnySymlinks(Path path, boolean neverFollowSymlinks) {
+ try {
+ if (!neverFollowSymlinks) {
+ path.getFileSystem().provider().checkAccess(path);
+ } else {
+ // this is a bad fix because this leaves a nasty race condition - the directory may turn into a symlink
+ // between this check and the call to open()
+ for (int i = 1; i <= path.getNameCount(); i++) {
+ Path checkForSymLink = getFirstPartsOfPath(path, i);
+ BasicFileAttributes basicFileAttributes
+ = Files.readAttributes(checkForSymLink, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
+ if (basicFileAttributes.isSymbolicLink()) {
+ return false;
+ }
+ }
+ }
+ return true;
+ } catch (NoSuchFileException e) {
+ return false;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Extracts the first n parts of the path. For example <br>
+ * ("/home/test/test12", 1) returns "/home", <br>
+ * ("/home/test", 1) returns "/home/test" <br>
+ * etc.
+ *
+ * @param path the path to extract parts of
+ * @param partsToExtract the number of parts to extract
+ * @return the extracted path
+ */
+ public static Path getFirstPartsOfPath(Path path, int partsToExtract) {
+ String firstName = path.getName(0).toString();
+ String[] names = new String[partsToExtract - 1];
+ for (int j = 1; j < partsToExtract; j++) {
+ names[j - 1] = path.getName(j).toString();
+ }
+ Path checkForSymLink = path.getFileSystem().getPath(firstName, names);
+ // the root is not counted as a directory part so we must resolve the result relative to it.
+ Path root = path.getRoot();
+ if (root != null) {
+ checkForSymLink = root.resolve(checkForSymLink);
+ }
+ return checkForSymLink;
+ }
+
/**
* Read the requested number of bytes or fail if there are not enough left.
*
@@ -637,4 +690,95 @@ public static List<String> readAllLines(BufferedReader reader, int lineCountHint
}
return result;
}
+
+ /**
+ * Chroot a path under the new root
+ *
+ * @param newRoot the new root
+ * @param toSanitize the path to sanitize and chroot
+ * @return the chrooted path under the newRoot filesystem
+ */
+ public static Path chroot(Path newRoot, Path toSanitize) {
+ Objects.requireNonNull(newRoot);
+ Objects.requireNonNull(toSanitize);
+ List<String> sanitized = removeExtraCdUps(toSanitize);
+ return buildPath(newRoot, newRoot.getFileSystem(), sanitized);
+ }
+
+ /**
+ * Remove any extra directory ups from the Path
+ *
+ * @param toSanitize the path to sanitize
+ * @return the sanitized path
+ */
+ public static Path removeCdUpAboveRoot(Path toSanitize) {
+ List<String> sanitized = removeExtraCdUps(toSanitize);
+ return buildPath(toSanitize.getRoot(), toSanitize.getFileSystem(), sanitized);
+ }
+
+ private static List<String> removeExtraCdUps(Path toResolve) {
+ List<String> newNames = new ArrayList<>(toResolve.getNameCount());
+
+ int numCdUps = 0;
+ int numDirParts = 0;
+ for (int i = 0; i < toResolve.getNameCount(); i++) {
+ String name = toResolve.getName(i).toString();
+ if ("..".equals(name)) {
+ // If we have more cdups than dir parts, so we ignore the ".." to avoid jail escapes
+ if (numDirParts > numCdUps) {
+ ++numCdUps;
+ newNames.add(name);
+ }
+ } else {
+ // if the current directory is a part of the name, don't increment number of dir parts, as it doesn't
+ // add to the number of ".."s that can be present before the root
+ if (!".".equals(name)) {
+ ++numDirParts;
+ }
+ newNames.add(name);
+ }
+ }
+ return newNames;
+ }
+
+ /**
+ * Build a path from the list of path parts
+ *
+ * @param root the root path
+ * @param fs the filesystem
+ * @param namesList the parts of the path to build
+ * @return the built path
+ */
+ public static Path buildPath(Path root, FileSystem fs, List<String> namesList) {
+ Objects.requireNonNull(fs);
+ if (namesList == null) {
+ return null;
+ }
+
+ if (GenericUtils.isEmpty(namesList)) {
+ return root == null ? fs.getPath(".") : root;
+ }
+
+ Path cleanedPathToResolve = buildRelativePath(fs, namesList);
+ return root == null ? cleanedPathToResolve : root.resolve(cleanedPathToResolve);
+ }
+
+ /**
+ * Build a relative path on the filesystem fs from the path parts in the namesList
+ *
+ * @param fs the filesystem for the path
+ * @param namesList the names list
+ * @return the built path
+ */
+ public static Path buildRelativePath(FileSystem fs, List<String> namesList) {
+ String[] names = new String[namesList.size() - 1];
+
+ Iterator<String> it = namesList.iterator();
+ String rootName = it.next();
+ for (int i = 0; it.hasNext(); i++) {
+ names[i] = it.next();
+ }
+ Path cleanedPathToResolve = fs.getPath(rootName, names);
+ return cleanedPathToResolve;
+ }
}
diff --git a/sshd-common/src/test/java/org/apache/sshd/common/file/root/RootedFileSystemProviderTest.java b/sshd-common/src/test/java/org/apache/sshd/common/file/root/RootedFileSystemProviderTest.java
index 9ec46b830..2520753ab 100644
--- a/sshd-common/src/test/java/org/apache/sshd/common/file/root/RootedFileSystemProviderTest.java
+++ b/sshd-common/src/test/java/org/apache/sshd/common/file/root/RootedFileSystemProviderTest.java
@@ -19,28 +19,29 @@
package org.apache.sshd.common.file.root;
+import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channel;
import java.nio.channels.FileChannel;
-import java.nio.file.DirectoryStream;
-import java.nio.file.FileSystem;
-import java.nio.file.Files;
-import java.nio.file.InvalidPathException;
-import java.nio.file.OpenOption;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.nio.file.StandardOpenOption;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.*;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
import java.util.Objects;
import java.util.Random;
import java.util.TreeSet;
import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.OsUtils;
import org.apache.sshd.util.test.CommonTestSupportUtils;
import org.apache.sshd.util.test.NoIoTestCase;
-import org.junit.BeforeClass;
+import org.junit.Assert;
+import org.junit.Assume;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.experimental.categories.Category;
@@ -49,7 +50,7 @@
/**
* Tests the RootedFileSystemProvider implementation of {@link java.nio.file.spi.FileSystemProvider} checking that
* permissions for generic FS commands are not permitted outside of the root directory.
- *
+ * <p>
* Individual tests are form pairs (e.g. testX, testXInvalid) where testXInvalid is expected to test a parent path of
* {@link RootedFileSystem#getRoot()}
*
@@ -58,19 +59,21 @@
@FixMethodOrder(MethodSorters.NAME_ASCENDING)
@Category({ NoIoTestCase.class })
public class RootedFileSystemProviderTest extends AssertableFile {
- private static RootedFileSystem fileSystem;
- private static Path rootSandbox;
+ private static final String SKIP_ON_WINDOWS = "Test fails due to windows normalizing paths before opening them, " +
+ "allowing one to open a file like \"C:\\directory_doesnt_exist\\..\\myfile.txt\" whereas this is blocked in unix";
+ private static final String DOESNT_EXIST = "../doesnt_exist/../";
- public RootedFileSystemProviderTest() {
- super();
- }
+ private final RootedFileSystem fileSystem;
+ private final Path rootSandbox;
+ private final FileHelper fileHelper;
- @BeforeClass
- public static void initializeFileSystem() throws IOException {
+ public RootedFileSystemProviderTest() throws Exception {
+ super();
+ fileHelper = new FileHelper();
Path targetFolder = Objects.requireNonNull(
CommonTestSupportUtils.detectTargetFolder(RootedFileSystemProviderTest.class),
"Failed to detect target folder");
- rootSandbox = FileHelper.createTestSandbox(targetFolder.resolve(TEMP_SUBFOLDER_NAME));
+ rootSandbox = fileHelper.createTestSandbox(targetFolder.resolve(TEMP_SUBFOLDER_NAME));
fileSystem = (RootedFileSystem) new RootedFileSystemProvider().newFileSystem(rootSandbox, Collections.emptyMap());
}
@@ -86,144 +89,216 @@ public void testRoot() {
/* mkdir */
@Test
public void testMkdir() throws IOException {
- Path created = FileHelper.createDirectory(fileSystem.getPath(getCurrentTestName()));
- assertTrue(exists(created) && isDir(created) && isReadable(created));
+ Path created = fileHelper.createDirectory(fileSystem.getPath(getCurrentTestName()));
+ try {
+ assertTrue(exists(created) && isDir(created) && isReadable(created));
+ } finally {
+ Files.delete(created);
+ }
}
- @Test(expected = InvalidPathException.class)
- public void testMkdirInvalid() throws IOException {
- Path parent = FileHelper.createDirectory(fileSystem.getPath("../" + getCurrentTestName()));
- fail(String.format("Unexpected success in creating directory %s", parent.toString()));
+ @Test
+ public void testMkdirInvalid() {
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+
+ String parent = DOESNT_EXIST + getCurrentTestName();
+ assertThrows(String.format("Unexpected success in creating directory %s", parent), NoSuchFileException.class,
+ () -> fileHelper.createDirectory(fileSystem.getPath(parent)));
}
/* rmdir */
@Test
public void testRmdir() throws IOException {
- Path created = FileHelper.createDirectory(fileSystem.getPath(getCurrentTestName()));
- Path deleted = FileHelper.deleteDirectory(created);
+ Path created = fileHelper.createDirectory(fileSystem.getPath(getCurrentTestName()));
+ Path deleted = fileHelper.deleteDirectory(created);
notExists(deleted);
}
- @Test(expected = InvalidPathException.class)
+ @Test(expected = NoSuchFileException.class)
public void testRmdirInvalid() throws IOException {
- Path deleted = FileHelper.deleteDirectory(fileSystem.getPath("../" + getCurrentTestName()));
+ Path deleted = fileHelper.deleteDirectory(fileSystem.getPath(DOESNT_EXIST + getCurrentTestName()));
fail(String.format("Unexpected success in removing directory %s", deleted.toString()));
}
/* chdir */
@Test
public void testChdir() throws IOException {
- Path created = FileHelper.createDirectory(fileSystem.getPath(getCurrentTestName()));
- Path createdFile = FileHelper.createFile(created.resolve(getCurrentTestName()));
- boolean hasFile = false;
- try (DirectoryStream<Path> ds = FileHelper.readDirectory(created)) {
- for (Path p : ds) {
- hasFile |= FileHelper.isSameFile(createdFile,
- fileSystem.getPath(created.getFileName() + "/" + p.getFileName()));
+ Path created = fileHelper.createDirectory(fileSystem.getPath(getCurrentTestName()));
+ Path createdFile = fileHelper.createFile(created.resolve(getCurrentTestName()));
+ try {
+ boolean hasFile = false;
+ try (DirectoryStream<Path> ds = fileHelper.readDirectory(created)) {
+ for (Path p : ds) {
+ hasFile |= fileHelper.isSameFile(createdFile,
+ fileSystem.getPath(created.getFileName() + "/" + p.getFileName()));
+ }
}
+ assertTrue(createdFile + " found in ch directory", hasFile);
+ } finally {
+ Files.delete(createdFile);
+ Files.delete(created);
}
- assertTrue(createdFile + " found in ch directory", hasFile);
- }
-
- @Test(expected = InvalidPathException.class)
- public void testChdirInvalid() throws IOException {
- Path chdir = FileHelper.createDirectory(fileSystem.getPath("../" + getCurrentTestName()));
- fail(String.format("Unexpected success in changing directory %s", chdir.toString()));
}
/* write */
@Test
public void testWriteFile() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
assertTrue(exists(created) && isReadable(created));
}
- @Test(expected = InvalidPathException.class)
+ @Test
public void testWriteFileInvalid() throws IOException {
- Path written = FileHelper.createFile(fileSystem.getPath("../" + getCurrentTestName()));
- fail(String.format("Unexpected success in writing file %s", written.toString()));
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+
+ String written = DOESNT_EXIST + getCurrentTestName();
+ assertThrows(String.format("Unexpected success in writing file %s", written), NoSuchFileException.class,
+ () -> fileHelper.createFile(fileSystem.getPath(written)));
}
/* read */
@Test
public void testReadFile() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
- isNonEmpty(FileHelper.readFile(created));
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ isNonEmpty(fileHelper.readFile(created));
}
- @Test(expected = InvalidPathException.class)
+ @Test(expected = NoSuchFileException.class)
public void testReadFileInvalid() throws IOException {
- Path read = fileSystem.getPath("../" + getCurrentTestName());
- FileHelper.readFile(read);
+ Path read = fileSystem.getPath(DOESNT_EXIST + getCurrentTestName());
+ fileHelper.readFile(read);
fail(String.format("Unexpected success in reading file %s", read.toString()));
}
/* rm */
@Test
public void testDeleteFile() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
- Path deleted = FileHelper.deleteFile(created);
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ Path deleted = fileHelper.deleteFile(created);
notExists(deleted);
}
- @Test(expected = InvalidPathException.class)
+ @Test(expected = NoSuchFileException.class)
public void testDeleteFileInvalid() throws IOException {
- Path deleted = FileHelper.deleteFile(fileSystem.getPath("../" + getCurrentTestName()));
+ Path deleted = fileHelper.deleteFile(fileSystem.getPath(DOESNT_EXIST + getCurrentTestName()));
fail(String.format("Unexpected success in deleting file %s", deleted.toString()));
}
/* cp */
@Test
public void testCopyFile() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
Path destination = fileSystem.getPath(getCurrentTestName() + "dest");
- FileHelper.copyFile(created, destination);
- assertTrue(exists(destination) && isReadable(destination));
+ try {
+ fileHelper.copyFile(created, destination);
+ assertTrue(exists(destination) && isReadable(destination));
+ } finally {
+ Files.delete(destination);
+ Files.delete(created);
+ }
}
- @Test(expected = InvalidPathException.class)
+ @Test
public void testCopyFileInvalid() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
- Path copy = FileHelper.copyFile(created, fileSystem.getPath("../" + getCurrentTestName()));
- fail(String.format("Unexpected success in copying file to %s", copy.toString()));
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ String copy = DOESNT_EXIST + getCurrentTestName();
+ assertThrows(String.format("Unexpected success in copying file to %s", copy),
+ NoSuchFileException.class,
+ () -> fileHelper.copyFile(created, fileSystem.getPath(copy)));
}
/* mv */
@Test
public void testMoveFile() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
Path destination = fileSystem.getPath(getCurrentTestName() + "dest");
- FileHelper.moveFile(created, destination);
+ fileHelper.moveFile(created, destination);
assertTrue(notExists(created) && exists(destination) && isReadable(destination));
}
- @Test(expected = InvalidPathException.class)
+ @Test
public void testMoveFileInvalid() throws IOException {
- Path created = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
- Path moved = FileHelper.moveFile(created, fileSystem.getPath("../" + getCurrentTestName()));
- fail(String.format("Unexpected success in moving file to %s", moved.toString()));
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+
+ Path created = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ String moved = DOESNT_EXIST + getCurrentTestName();
+ assertThrows(String.format("Unexpected success in moving file to %s", moved), NoSuchFileException.class,
+ () -> fileHelper.moveFile(created, fileSystem.getPath(moved)));
}
/* link */
@Test
public void testCreateLink() throws IOException {
- Path existing = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ Path existing = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
Path link = fileSystem.getPath(getCurrentTestName() + "link");
- FileHelper.createLink(link, existing);
- assertTrue(exists(link) && isReadable(link));
+ try {
+ fileHelper.createLink(link, existing);
+ assertTrue(exists(link) && isReadable(link));
+ } finally {
+ Files.delete(link);
+ Files.delete(existing);
+ }
+ }
+
+ @Test
+ public void testJailbreakLink() throws IOException {
+ testJailbreakLink("../");
+ }
+
+ @Test
+ public void testJailbreakLink2() throws IOException {
+ testJailbreakLink("../test/");
+ }
+
+ @Test
+ public void testJailbreakLink3() throws IOException {
+ testJailbreakLink("/..");
+ }
+
+ @Test
+ public void testJailbreakLink4() throws IOException {
+ testJailbreakLink("/./..");
+ }
+
+ @Test
+ public void testJailbreakLink5() throws IOException {
+ testJailbreakLink("/./../");
+ }
+
+ @Test
+ public void testJailbreakLink6() throws IOException {
+ testJailbreakLink("./../");
+ }
+
+ @Test
+ public void testJailbreakLink7() throws IOException {
+ String fileName = "/testdir/testdir2/../../..";
+ testJailbreakLink(fileName);
}
- @Test(expected = InvalidPathException.class)
+ private void testJailbreakLink(String jailbrokenTarget) throws IOException {
+ Path target = fileSystem.getPath(jailbrokenTarget);
+ Path linkPath = fileSystem.getPath("/testLink");
+ Assert.assertThrows(InvalidPathException.class, () -> fileSystem.provider().createSymbolicLink(linkPath, target));
+ Assert.assertFalse(Files.exists(linkPath));
+ }
+
+ @Test
public void testCreateLinkInvalid() throws IOException {
- Path existing = FileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
- Path link = FileHelper.createLink(fileSystem.getPath("../" + getCurrentTestName() + "link"), existing);
- fail(String.format("Unexpected success in linking file %s", link.toString()));
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+
+ Path existing = fileHelper.createFile(fileSystem.getPath(getCurrentTestName()));
+ String link = DOESNT_EXIST + getCurrentTestName() + "link";
+ assertThrows(String.format("Unexpected success in linking file %s", link), NoSuchFileException.class,
+ () -> fileHelper.createLink(fileSystem.getPath(link), existing));
}
@Test
public void testNewByteChannelProviderMismatchException() throws IOException {
RootedFileSystemProvider provider = new RootedFileSystemProvider();
- Path tempFolder = assertHierarchyTargetFolderExists(getTempTargetFolder());
+ Path tempFolder = getTempTargetFolder();
Path file = Files.createTempFile(tempFolder, getCurrentTestName(), ".txt");
try (FileSystem fs = provider.newFileSystem(tempFolder, Collections.emptyMap());
Channel channel = provider.newByteChannel(fs.getPath(file.getFileName().toString()), Collections.emptySet())) {
@@ -235,23 +310,212 @@ public void testNewByteChannelProviderMismatchException() throws IOException {
public void testResolveRoot() throws IOException {
Path root = GenericUtils.head(fileSystem.getRootDirectories());
Path dir = root.resolve("tsd");
- FileHelper.createDirectory(dir);
- Path f1 = FileHelper.createFile(dir.resolve("test.txt"));
- Path f2 = Files.newDirectoryStream(dir).iterator().next();
- assertTrue("Unrooted path found", f2 instanceof RootedPath);
- assertEquals(f1, f2);
- FileHelper.deleteFile(f1);
- FileHelper.deleteDirectory(dir);
+ fileHelper.createDirectory(dir);
+ Path f1 = fileHelper.createFile(dir.resolve("test.txt"));
+ try {
+ Path f2;
+ try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir)) {
+ f2 = ds.iterator().next();
+ }
+ assertTrue("Unrooted path found", f2 instanceof RootedPath);
+ assertEquals(f1, f2);
+ } finally {
+ fileHelper.deleteFile(f1);
+ fileHelper.deleteDirectory(dir);
+ }
+ }
+
+ @Test
+ public void testBreakOutOfChroot1() throws IOException {
+ String fileName = "../" + getCurrentTestName();
+ testBreakOutOfChroot(fileName, fileName);
+ }
+
+ @Test
+ public void testBreakOutOfChroot2() throws IOException {
+ String fileName = "./../" + getCurrentTestName();
+ testBreakOutOfChroot(fileName, fileName);
+ }
+
+ @Test
+ public void testBreakOutOfChroot3() throws IOException {
+ String fileName = "/../" + getCurrentTestName();
+ testBreakOutOfChroot(fileName, fileName);
+ }
+
+ @Test
+ public void testBreakOutOfChroot4() throws IOException {
+ String fileName = "/.././" + getCurrentTestName();
+ testBreakOutOfChroot(fileName, fileName);
+ }
+
+ @Test
+ public void testBreakOutOfChroot5() throws IOException {
+ String fileName = "/./../" + getCurrentTestName();
+ testBreakOutOfChroot(fileName, fileName);
+ }
+
+ @Test
+ public void testBreakOutOfChroot6() throws IOException {
+ String fileName = "//../" + getCurrentTestName();
+ testBreakOutOfChroot(fileName, "/../" + getCurrentTestName());
+ }
+
+ /**
+ * Tests to make sure that the attempted break out of the chroot does not work with the specified filename
+ *
+ * @param fileName the filename to attempt to break out of the chroot with
+ * @throws IOException on test failure
+ */
+ private void testBreakOutOfChroot(String fileName, String expected) throws IOException {
+ RootedPath breakoutAttempt = fileSystem.getPath(fileName);
+
+ // make sure that our rooted fs behaves like a proper unix fs
+ Assert.assertEquals(expected, breakoutAttempt.toString());
+
+ Path expectedDir = fileSystem.getRoot().resolve(getCurrentTestName());
+ Path newDir = fileHelper.createDirectory(breakoutAttempt);
+ try {
+ assertTrue(Files.isDirectory(expectedDir));
+
+ String baseName = breakoutAttempt.getName(breakoutAttempt.getNameCount() - 1).toString();
+ assertTrue(fileHelper.isSameFile(newDir, fileSystem.getPath(baseName)));
+
+ // make sure we didn't create it one directory out of the jail
+ assertFalse(Files.exists(fileSystem.getRoot().resolve("../" + breakoutAttempt.getFileName().toString())));
+
+ // make sure various methods of referencing the file work.
+ assertTrue(fileHelper.isSameFile(newDir, fileSystem.getPath("/" + baseName)));
+ assertTrue(fileHelper.isSameFile(newDir, fileSystem.getPath("/../../" + baseName)));
+ assertTrue(fileHelper.isSameFile(newDir, fileSystem.getPath("./../" + baseName)));
+ } finally {
+ // cleanup the directory.
+ fileHelper.deleteDirectory(newDir);
+ }
+
+ assertFalse(Files.isDirectory(expectedDir));
+ assertFalse(Files.isDirectory(newDir));
+ }
+
+ @Test
+ public void testValidSymlink1() throws IOException {
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+ String fileName = "/testdir/../";
+ testValidSymlink(fileName, true);
+ }
+
+ @Test
+ public void testValidSymlink2() throws IOException {
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+ String fileName = "/testdir/testdir2/../";
+ testValidSymlink(fileName, true);
+ }
+
+ @Test
+ public void testValidSymlink3() throws IOException {
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+ String fileName = "/testdir/../testdir3/";
+ testValidSymlink(fileName, true);
+ }
+
+ @Test
+ public void testValidSymlink4() throws IOException {
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+ String fileName = "testdir/../testdir3/../";
+ testValidSymlink(fileName, true);
+ }
+
+ @Test
+ public void testValidSymlink5() throws IOException {
+ Assume.assumeFalse(SKIP_ON_WINDOWS, OsUtils.isWin32());
+ String fileName = "testdir/../testdir3/../testfile";
+ testValidSymlink(fileName, false);
+ }
+
+ public void testValidSymlink(String symlink, boolean targetIsDirectory) throws IOException {
+ Path target = fileSystem.getPath(symlink);
+ Path linkPath = fileSystem.getPath("/testLink");
+ final List<Path> toDelete = new ArrayList<>();
+ try {
+ fileSystem.provider().createSymbolicLink(linkPath, target);
+ toDelete.add(linkPath);
+
+ // ensure that nothing processed the symlink.
+ Assert.assertEquals(Paths.get(symlink).toString(),
+ fileSystem.provider().readSymbolicLink(linkPath).toString());
+ Assert.assertFalse(Files.exists(target));
+ Assert.assertEquals(Files.exists(linkPath), Files.exists(target));
+
+ // If we don't follow the link, we simply check that the link exists, which it does as we created it.
+ Assert.assertTrue(Files.exists(linkPath, LinkOption.NOFOLLOW_LINKS));
+
+ createParentDirs(targetIsDirectory ? target : target.getParent(), toDelete);
+
+ if (!targetIsDirectory) {
+ Files.createFile(target);
+ toDelete.add(target);
+ }
+
+ Assert.assertTrue(Files.exists(linkPath));
+ } finally {
+ for (int i = toDelete.size() - 1; i >= 0; i--) {
+ Path path = toDelete.get(i);
+ try {
+ Files.delete(path);
+ } catch (IOException ex) {
+ // ignore as we might try to delete "/dir/.." which will fail as it contains dir..
+ }
+ }
+ }
+ }
+
+ private static void createParentDirs(Path target, List<Path> toDelete) throws IOException {
+ if (target.getParent() != null) {
+ createParentDirs(target.getParent(), toDelete);
+ }
+
+ if (!Files.isDirectory(target)) {
+ Files.createDirectories(target);
+ toDelete.add(target);
+ }
+ }
+
+ @Test
+ public void testFileNamedSlashOnUnixBasedOS() throws IOException {
+ // skip ths test on Win32
+ if (!"\\".equals(File.separator)) {
+ Path slashFile = fileSystem.getPath("\\");
+ Path created = fileHelper.createFile(slashFile);
+ try {
+ assertTrue(Files.isRegularFile(created));
+ } finally {
+ fileHelper.deleteFile(created);
+ }
+ }
+ }
+
+ @Test
+ public void testStreams() throws IOException {
+ byte[] data = "This is test data".getBytes(StandardCharsets.UTF_8);
+ RootedPath testPath = fileSystem.getPath("testfile.txt");
+ try (OutputStream is = Files.newOutputStream(testPath)) {
+ is.write(data);
+ }
+ byte[] read = new byte[data.length];
+ try (InputStream is = Files.newInputStream(testPath)) {
+ is.read(read);
+ }
+ assertArrayEquals(data, read);
}
/* Private helper */
/**
* Wrapper around the FileSystemProvider to test generic FS related commands. All created temp directories and files
- * used for testing are deleted upon JVM exit.
+ * used for testing must be deleted in the test which creates them
*/
@SuppressWarnings("synthetic-access")
- private static final class FileHelper {
+ private final class FileHelper {
private FileHelper() {
super();
}
@@ -263,71 +527,66 @@ private FileHelper() {
* @return the created sandbox Path
* @throws IOException on failure to create
*/
- public static Path createTestSandbox(Path tempDir) throws IOException {
- Path created = Files.createDirectories(tempDir.resolve(RootedFileSystemProviderTest.class.getSimpleName()));
- created.toFile().deleteOnExit();
+ public Path createTestSandbox(Path tempDir) throws IOException {
+ Path path = tempDir.resolve(RootedFileSystemProviderTest.class.getSimpleName());
+ Path created = Files.createDirectories(path);
return created;
}
- public static Path createFile(Path source) throws InvalidPathException, IOException {
+ public Path createFile(Path source) throws InvalidPathException, IOException {
try (FileChannel fc = fileSystem.provider().newFileChannel(source,
new TreeSet<OpenOption>(Arrays.asList(StandardOpenOption.CREATE, StandardOpenOption.WRITE)))) {
byte[] randomBytes = new byte[1000];
new Random().nextBytes(randomBytes);
fc.write(ByteBuffer.wrap(randomBytes));
- source.toFile().deleteOnExit();
return source;
}
}
- public static Path createLink(Path link, Path existing) throws IOException {
+ public Path createLink(Path link, Path existing) throws IOException {
fileSystem.provider().createLink(link, existing);
- link.toFile().deleteOnExit();
return link;
}
- public static Path createDirectory(Path dir) throws InvalidPathException, IOException {
+ public Path createDirectory(Path dir) throws InvalidPathException, IOException {
fileSystem.provider().createDirectory(dir);
- dir.toFile().deleteOnExit();
return dir;
}
- public static Path deleteDirectory(Path dir) throws InvalidPathException, IOException {
+ public Path deleteDirectory(Path dir) throws InvalidPathException, IOException {
return deleteFile(dir);
}
- public static Path deleteFile(Path source) throws InvalidPathException, IOException {
+ public Path deleteFile(Path source) throws InvalidPathException, IOException {
fileSystem.provider().delete(source);
return source;
}
- public static byte[] readFile(Path source) throws IOException {
+ public byte[] readFile(Path source) throws IOException {
try (FileChannel fc = fileSystem.provider().newFileChannel(source,
- new TreeSet<OpenOption>(Arrays.asList(StandardOpenOption.READ)))) {
- byte[] readBytes = new byte[(int) source.toFile().length()];
+ Collections.singleton(StandardOpenOption.READ))) {
+ byte[] readBytes = new byte[(int) Files.size(source)];
fc.read(ByteBuffer.wrap(readBytes));
return readBytes;
}
}
- public static Path copyFile(Path source, Path destination) throws InvalidPathException, IOException {
+ public Path copyFile(Path source, Path destination) throws InvalidPathException, IOException {
fileSystem.provider().copy(source, destination, StandardCopyOption.COPY_ATTRIBUTES);
- destination.toFile().deleteOnExit();
return destination;
}
- public static Path moveFile(Path source, Path destination) throws InvalidPathException, IOException {
+ public Path moveFile(Path source, Path destination) throws InvalidPathException, IOException {
fileSystem.provider().move(source, destination, StandardCopyOption.ATOMIC_MOVE);
- destination.toFile().deleteOnExit();
return destination;
}
- public static DirectoryStream<Path> readDirectory(Path dir) throws InvalidPathException, IOException {
+ public DirectoryStream<Path> readDirectory(Path dir) throws InvalidPathException, IOException {
DirectoryStream<Path> dirStream = fileSystem.provider().newDirectoryStream(dir, entry -> true);
return dirStream;
}
- public static boolean isSameFile(Path source, Path destination) throws IOException {
+ public boolean isSameFile(Path source, Path destination) throws IOException {
return fileSystem.provider().isSameFile(source, destination);
}
}
diff --git a/sshd-common/src/test/java/org/apache/sshd/common/util/io/IoUtilsTest.java b/sshd-common/src/test/java/org/apache/sshd/common/util/io/IoUtilsTest.java
index a94811dc5..247e3d655 100644
--- a/sshd-common/src/test/java/org/apache/sshd/common/util/io/IoUtilsTest.java
+++ b/sshd-common/src/test/java/org/apache/sshd/common/util/io/IoUtilsTest.java
@@ -19,11 +19,17 @@
package org.apache.sshd.common.util.io;
+import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import org.apache.sshd.common.util.NumberUtils;
+import org.apache.sshd.util.test.CommonTestSupportUtils;
import org.apache.sshd.util.test.JUnitTestSupport;
import org.apache.sshd.util.test.NoIoTestCase;
+import org.junit.Assert;
import org.junit.FixMethodOrder;
import org.junit.Test;
import org.junit.experimental.categories.Category;
@@ -58,4 +64,46 @@ public void testGetEOLBytes() {
}
}
+ /**
+ * Tests to make sure check exists does not follow symlinks.
+ *
+ * @throws IOException on failure
+ */
+ @Test
+ public void testCheckExists() throws IOException {
+ testCheckExists(Paths.get("target/IoUtilsTest").toAbsolutePath());
+ }
+
+ public void testCheckExists(Path baseDir) throws IOException {
+ CommonTestSupportUtils.deleteRecursive(baseDir, LinkOption.NOFOLLOW_LINKS);
+
+ Path folder = baseDir.resolve("folder1/folder2/");
+ Files.createDirectories(folder);
+
+ Path target = baseDir.resolve("folder1/target");
+ Files.createDirectories(target);
+
+ Path dirInTarget = baseDir.resolve("folder1/target/dirintarget");
+ Files.createDirectories(dirInTarget);
+
+ Files.createDirectories(target);
+ Path link = baseDir.resolve("folder1/folder2/link");
+ Files.createSymbolicLink(link, target);
+
+ Path link2 = baseDir.resolve("link");
+ Files.createSymbolicLink(link2, target);
+
+ Path targetWithLink = baseDir.resolve("folder1/folder2/link/dirintarget");
+
+ Assert.assertTrue("symlink follow should work", IoUtils.checkFileExists(targetWithLink));
+ Assert.assertTrue("symlink follow should work", IoUtils.checkFileExistsAnySymlinks(targetWithLink, false));
+
+ Assert.assertFalse("Link at end shouldn't be followed", IoUtils.checkFileExistsAnySymlinks(link, true));
+ Assert.assertFalse("Nofollow shouldn't follow directory",
+ IoUtils.checkFileExistsAnySymlinks(targetWithLink, true));
+ Assert.assertFalse("Link at beginning shouldn't be followed",
+ IoUtils.checkFileExistsAnySymlinks(link2, true));
+ Assert.assertTrue("Root directory must exist",
+ IoUtils.checkFileExistsAnySymlinks(baseDir, true));
+ }
}
diff --git a/sshd-common/src/test/java/org/apache/sshd/util/test/CommonTestSupportUtils.java b/sshd-common/src/test/java/org/apache/sshd/util/test/CommonTestSupportUtils.java
index 829f25bcd..57f43fbe3 100644
--- a/sshd-common/src/test/java/org/apache/sshd/util/test/CommonTestSupportUtils.java
+++ b/sshd-common/src/test/java/org/apache/sshd/util/test/CommonTestSupportUtils.java
@@ -495,11 +495,11 @@ private static Path getFile(String resource) {
* @throws IOException If failed to access/remove some file(s)
*/
public static Path deleteRecursive(Path path, LinkOption... options) throws IOException {
- if ((path == null) || (!Files.exists(path))) {
+ if ((path == null) || (!Files.exists(path, options))) {
return path;
}
- if (Files.isDirectory(path)) {
+ if (Files.isDirectory(path, options)) {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(path)) {
for (Path child : ds) {
deleteRecursive(child, options);
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/sftp/client/fs/SftpFileSystemProvider.java b/sshd-sftp/src/main/java/org/apache/sshd/sftp/client/fs/SftpFileSystemProvider.java
index 4ea058e69..23664be71 100644
--- a/sshd-sftp/src/main/java/org/apache/sshd/sftp/client/fs/SftpFileSystemProvider.java
+++ b/sshd-sftp/src/main/java/org/apache/sshd/sftp/client/fs/SftpFileSystemProvider.java
@@ -621,7 +621,7 @@ public void copy(Path source, Path target, CopyOption... options) throws IOExcep
}
// delete target if it exists and REPLACE_EXISTING is specified
- Boolean status = IoUtils.checkFileExists(target, linkOptions);
+ Boolean status = IoUtils.checkFileExistsAnySymlinks(target, noFollowLinks);
if (status == null) {
throw new AccessDeniedException("Existence cannot be determined for copy target: " + target);
}
@@ -698,7 +698,7 @@ public void move(Path source, Path target, CopyOption... options) throws IOExcep
}
// delete target if it exists and REPLACE_EXISTING is specified
- Boolean status = IoUtils.checkFileExists(target, linkOptions);
+ Boolean status = IoUtils.checkFileExistsAnySymlinks(target, noFollowLinks);
if (status == null) {
throw new AccessDeniedException("Existence cannot be determined for move target " + target);
}
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/AbstractSftpSubsystemHelper.java b/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/AbstractSftpSubsystemHelper.java
index 425234e96..035243daf 100644
--- a/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/AbstractSftpSubsystemHelper.java
+++ b/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/AbstractSftpSubsystemHelper.java
@@ -36,6 +36,7 @@
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.NotDirectoryException;
+import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
@@ -51,7 +52,6 @@
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
-import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedList;
@@ -645,9 +645,11 @@ protected Map<String, Object> doLStat(int id, String path, int flags) throws IOE
* SSH_FXP_LSTAT does not.
*/
SftpFileSystemAccessor accessor = getFileSystemAccessor();
+
+ boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_LSTAT, "", p);
LinkOption[] options = accessor.resolveFileAccessLinkOptions(
this, p, SftpConstants.SSH_FXP_LSTAT, "", false);
- return resolveFileAttributes(p, flags, options);
+ return resolveFileAttributes(p, flags, !followLinks, options);
}
protected void doSetStat(
@@ -675,7 +677,7 @@ protected void doSetStat(
Path p = resolveFile(path);
if (followLinks == null) {
- followLinks = resolvePathResolutionFollowLinks(cmd, extension, p);
+ followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_SETSTAT, extension, p);
}
doSetAttributes(cmd, extension, p, attrs, followLinks);
}
@@ -1408,12 +1410,14 @@ protected Map<String, Object> doStat(int id, String path, int flags) throws IOEx
*/
Path p = resolveFile(path);
SftpFileSystemAccessor accessor = getFileSystemAccessor();
+ boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_STAT, "", p);
LinkOption[] options = accessor.resolveFileAccessLinkOptions(
- this, p, SftpConstants.SSH_FXP_STAT, "", true);
- return resolveFileAttributes(p, flags, options);
+ this, p, SftpConstants.SSH_FXP_STAT, "", followLinks);
+ return resolveFileAttributes(p, flags, !followLinks, options);
}
protected void doRealPath(Buffer buffer, int id) throws IOException {
+ // do things here.
String path = buffer.getString();
boolean debugEnabled = log.isDebugEnabled();
ServerSession session = getServerSession();
@@ -1467,7 +1471,6 @@ protected void doRealPath(Buffer buffer, int id) throws IOException {
result = doRealPathV6(id, path, extraPaths, p, options);
p = result.getKey();
- options = getPathResolutionLinkOption(SftpConstants.SSH_FXP_REALPATH, "", p);
Boolean status = result.getValue();
switch (control) {
case SftpConstants.SSH_FXP_REALPATH_STAT_IF:
@@ -1557,14 +1560,14 @@ protected SimpleImmutableEntry<Path, Boolean> validateRealPath(
int id, String path, Path f, LinkOption... options)
throws IOException {
Path p = normalize(f);
- Boolean status = IoUtils.checkFileExists(p, options);
+ Boolean status = IoUtils.checkFileExistsAnySymlinks(p, !IoUtils.followLinks(options));
return new SimpleImmutableEntry<>(p, status);
}
protected void doRemoveDirectory(Buffer buffer, int id) throws IOException {
String path = buffer.getString();
try {
- doRemoveDirectory(id, path, false);
+ doRemoveDirectory(id, path);
} catch (IOException | RuntimeException e) {
sendStatus(prepareReply(buffer), id, e,
SftpConstants.SSH_FXP_RMDIR, path);
@@ -1574,15 +1577,23 @@ protected void doRemoveDirectory(Buffer buffer, int id) throws IOException {
sendStatus(prepareReply(buffer), id, SftpConstants.SSH_FX_OK, "");
}
- protected void doRemoveDirectory(int id, String path, boolean followLinks) throws IOException {
+ protected void doRemoveDirectory(int id, String path) throws IOException {
Path p = resolveFile(path);
if (log.isDebugEnabled()) {
log.debug("doRemoveDirectory({})[id={}] SSH_FXP_RMDIR (path={})[{}]", getServerSession(), id, path, p);
}
SftpFileSystemAccessor accessor = getFileSystemAccessor();
+
+ final boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_RMDIR, "", p);
+ Boolean symlinkCheck = validateParentExistWithNoSymlinksIfNeverFollowSymlinks(p, !followLinks);
+ if (!Boolean.TRUE.equals(symlinkCheck)) {
+ throw new AccessDeniedException(p.toString(), p.toString(),
+ "Parent directories do not exist ore are prohibited symlinks");
+ }
+
LinkOption[] options = accessor.resolveFileAccessLinkOptions(
- this, p, SftpConstants.SSH_FXP_RMDIR, "", followLinks);
+ this, p, SftpConstants.SSH_FXP_RMDIR, "", false);
if (Files.isDirectory(p, options)) {
doRemove(id, p, true);
} else {
@@ -1618,7 +1629,7 @@ protected void doMakeDirectory(Buffer buffer, int id) throws IOException {
String path = buffer.getString();
Map<String, ?> attrs = readAttrs(buffer);
try {
- doMakeDirectory(id, path, attrs, false);
+ doMakeDirectory(id, path, attrs);
} catch (IOException | RuntimeException e) {
sendStatus(prepareReply(buffer), id, e,
SftpConstants.SSH_FXP_MKDIR, path, attrs);
@@ -1629,7 +1640,7 @@ protected void doMakeDirectory(Buffer buffer, int id) throws IOException {
}
protected void doMakeDirectory(
- int id, String path, Map<String, ?> attrs, boolean followLinks)
+ int id, String path, Map<String, ?> attrs)
throws IOException {
Path resolvedPath = resolveFile(path);
ServerSession session = getServerSession();
@@ -1640,14 +1651,21 @@ protected void doMakeDirectory(
SftpFileSystemAccessor accessor = getFileSystemAccessor();
LinkOption[] options = accessor.resolveFileAccessLinkOptions(
- this, resolvedPath, SftpConstants.SSH_FXP_MKDIR, "", followLinks);
+ this, resolvedPath, SftpConstants.SSH_FXP_MKDIR, "", false);
+ final boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_MKDIR, "", resolvedPath);
SftpPathImpl.withAttributeCache(resolvedPath, p -> {
- Boolean status = IoUtils.checkFileExists(p, options);
- if (status == null) {
+ Boolean symlinkCheck = validateParentExistWithNoSymlinksIfNeverFollowSymlinks(p, !followLinks);
+ if (!Boolean.TRUE.equals(symlinkCheck)) {
+ throw new AccessDeniedException(p.toString(), p.toString(),
+ "Parent directories do not exist ore are prohibited symlinks");
+ }
+
+ Boolean fileExists = IoUtils.checkFileExists(p, options);
+ if (fileExists == null) {
throw new AccessDeniedException(p.toString(), p.toString(), "Cannot validate make-directory existence");
}
- if (status) {
+ if (fileExists) {
if (Files.isDirectory(p, options)) {
throw new FileAlreadyExistsException(p.toString(), p.toString(), "Target directory already exists");
} else {
@@ -1661,7 +1679,6 @@ protected void doMakeDirectory(
listener.creating(session, resolvedPath, attrs);
try {
accessor.createDirectory(this, resolvedPath);
- followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_MKDIR, "", resolvedPath);
doSetAttributes(SftpConstants.SSH_FXP_MKDIR, "", resolvedPath, attrs, followLinks);
} catch (IOException | RuntimeException | Error e) {
listener.created(session, resolvedPath, attrs, e);
@@ -1676,7 +1693,7 @@ protected void doRemove(Buffer buffer, int id) throws IOException {
/*
* If 'filename' is a symbolic link, the link is removed, not the file it points to.
*/
- doRemoveFile(id, path, false);
+ doRemoveFile(id, path);
} catch (IOException | RuntimeException e) {
sendStatus(prepareReply(buffer), id, e, SftpConstants.SSH_FXP_REMOVE, path);
return;
@@ -1685,17 +1702,20 @@ protected void doRemove(Buffer buffer, int id) throws IOException {
sendStatus(prepareReply(buffer), id, SftpConstants.SSH_FX_OK, "");
}
- protected void doRemoveFile(int id, String path, boolean followLinks) throws IOException {
+ protected void doRemoveFile(int id, String path) throws IOException {
Path resolvedPath = resolveFile(path);
if (log.isDebugEnabled()) {
log.debug("doRemoveFile({})[id={}] SSH_FXP_REMOVE (path={}[{}])", getServerSession(), id, path, resolvedPath);
}
+ // whether to follow links in the dir up to the final file
+ boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_REMOVE, "", resolvedPath);
SftpFileSystemAccessor accessor = getFileSystemAccessor();
+ // never resolve links in the final path to remove as we want to remove the symlink, not the target
LinkOption[] options = accessor.resolveFileAccessLinkOptions(
- this, resolvedPath, SftpConstants.SSH_FXP_REMOVE, "", followLinks);
+ this, resolvedPath, SftpConstants.SSH_FXP_REMOVE, "", false);
SftpPathImpl.withAttributeCache(resolvedPath, p -> {
- Boolean status = IoUtils.checkFileExists(p, options);
+ Boolean status = checkSymlinkState(p, followLinks, options);
if (status == null) {
throw signalRemovalPreConditionFailure(id, path, p,
new AccessDeniedException(p.toString(), p.toString(), "Cannot determine existence of remove candidate"),
@@ -2293,8 +2313,9 @@ protected void writeDirEntry(
int id, DirectoryHandle dir, Map<String, Path> entries, Buffer buffer,
int index, Path f, String shortName, LinkOption... options)
throws IOException {
+ boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_READDIR, "", f);
Map<String, ?> attrs = resolveFileAttributes(
- f, SftpConstants.SSH_FILEXFER_ATTR_ALL, options);
+ f, SftpConstants.SSH_FILEXFER_ATTR_ALL, !followLinks, options);
entries.put(shortName, f);
SftpFileSystemAccessor accessor = getFileSystemAccessor();
@@ -2392,10 +2413,10 @@ protected String getShortName(Path f) throws IOException {
}
protected NavigableMap<String, Object> resolveFileAttributes(
- Path path, int flags, LinkOption... options)
+ Path path, int flags, boolean neverFollowSymLinks, LinkOption... options)
throws IOException {
return SftpPathImpl.withAttributeCache(path, file -> {
- Boolean status = IoUtils.checkFileExists(file, options);
+ Boolean status = checkSymlinkState(file, neverFollowSymLinks, options);
if (status == null) {
return handleUnknownStatusFileAttributes(file, flags, options);
} else if (!status) {
@@ -2406,7 +2427,31 @@ protected NavigableMap<String, Object> resolveFileAttributes(
});
}
- protected void writeAttrs(Buffer buffer, Map<String, ?> attributes) throws IOException {
+ /**
+ * A utility function to validate that the directories leading up to a file are not symlinks
+ *
+ * @param path the file to check for symlink presence
+ * @param neverFollowSymLinks whether to never follow symlinks in the parent paths
+ * @param options whether the file itself can be a symlink
+ * @return whether there are symlinks in the path to this file, or null if unknown
+ */
+ public Boolean checkSymlinkState(Path path, boolean neverFollowSymLinks, LinkOption[] options) {
+ Boolean status = validateParentExistWithNoSymlinksIfNeverFollowSymlinks(path, neverFollowSymLinks);
+ if (!Boolean.FALSE.equals(status)) {
+ status = IoUtils.checkFileExists(path, options);
+ }
+ return status;
+ }
+
+ public Boolean validateParentExistWithNoSymlinksIfNeverFollowSymlinks(Path path, boolean neverFollowSymLinks) {
+ Boolean status = true;
+ if (neverFollowSymLinks && path.getParent() != null) {
+ status = IoUtils.checkFileExistsAnySymlinks(path.getParent(), true);
+ }
+ return status;
+ }
+
+ protected void writeAttrs(Buffer buffer, Map<String, ?> attributes) {
SftpHelper.writeAttrs(buffer, getVersion(), attributes);
}
@@ -2653,7 +2698,11 @@ protected void setFileAttributes(
case IoUtils.SIZE_VIEW_ATTR: {
long newSize = ((Number) value).longValue();
SftpFileSystemAccessor accessor = getFileSystemAccessor();
- Set<StandardOpenOption> openOptions = EnumSet.of(StandardOpenOption.WRITE);
+ Set<OpenOption> openOptions = new HashSet<>();
+ openOptions.add(StandardOpenOption.WRITE);
+ if (!IoUtils.followLinks(options)) {
+ openOptions.add(LinkOption.NOFOLLOW_LINKS);
+ }
try (SeekableByteChannel channel = accessor.openFile(this, null, file, null, openOptions)) {
channel.truncate(newSize);
accessor.closeFile(this, null, file, null, channel, openOptions);
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpFileSystemAccessor.java b/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpFileSystemAccessor.java
index 6d7c18fc1..2f6ab0553 100644
--- a/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpFileSystemAccessor.java
+++ b/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpFileSystemAccessor.java
@@ -19,20 +19,14 @@
package org.apache.sshd.sftp.server;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.StreamCorruptedException;
import java.nio.channels.Channel;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.SeekableByteChannel;
-import java.nio.file.CopyOption;
-import java.nio.file.DirectoryStream;
-import java.nio.file.FileSystem;
-import java.nio.file.Files;
-import java.nio.file.InvalidPathException;
-import java.nio.file.LinkOption;
-import java.nio.file.OpenOption;
-import java.nio.file.Path;
+import java.nio.file.*;
import java.nio.file.attribute.AclEntry;
import java.nio.file.attribute.AclFileAttributeView;
import java.nio.file.attribute.FileAttribute;
@@ -44,8 +38,10 @@
import java.nio.file.attribute.UserPrincipalLookupService;
import java.nio.file.attribute.UserPrincipalNotFoundException;
import java.security.Principal;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
@@ -214,7 +210,11 @@ default SeekableByteChannel openFile(
attrs = IoUtils.EMPTY_FILE_ATTRIBUTES;
}
- return FileChannel.open(file, options, attrs);
+ // Don't use Set contains as this can fail with TreeSet
+ if (!Arrays.asList(options.toArray(new OpenOption[0])).contains(LinkOption.NOFOLLOW_LINKS)) {
+ return FileChannel.open(file, options, attrs);
+ }
+ return seekableByteChannelNoLinkFollow(file, options, attrs);
}
/**
@@ -317,9 +317,12 @@ default void closeFile(
* @throws IOException If failed to open
*/
default DirectoryStream<Path> openDirectory(
- SftpSubsystemProxy subsystem, DirectoryHandle dirHandle, Path dir, String handle)
+ SftpSubsystemProxy subsystem, DirectoryHandle dirHandle, Path dir, String handle, LinkOption... linkOptions)
throws IOException {
- return Files.newDirectoryStream(dir);
+ if (IoUtils.followLinks(linkOptions)) {
+ return Files.newDirectoryStream(dir);
+ }
+ return secureResolveDirectoryStream(dir);
}
/**
@@ -523,15 +526,84 @@ default void renameFile(
default void copyFile(
SftpSubsystemProxy subsystem, Path src, Path dst, Collection<CopyOption> opts)
throws IOException {
+
+ if (noFollow(opts)) {
+ try (SeekableByteChannel srcFile
+ = seekableByteChannelNoLinkFollow(src, Collections.singleton(StandardOpenOption.READ))) {
+ if (!(srcFile instanceof FileChannel)) {
+ throw new UnsupportedOperationException("Host file system must return a file channel");
+ }
+
+ try (SeekableByteChannel dstFile = seekableByteChannelNoLinkFollow(src, new HashSet<>(Arrays
+ .asList(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE)))) {
+ ((FileChannel) srcFile).transferTo(0, srcFile.size(), dstFile);
+ }
+ }
+ return;
+ }
Files.copy(src, dst,
GenericUtils.isEmpty(opts)
? IoUtils.EMPTY_COPY_OPTIONS
: opts.toArray(new CopyOption[opts.size()]));
}
+ static SeekableByteChannel seekableByteChannelNoLinkFollow(
+ Path src, Set<? extends OpenOption> opts, FileAttribute<?>... fileAttributes)
+ throws IOException {
+ if (src.getNameCount() < 1) {
+ // opening root directory isn't supported.
+ throw new IllegalArgumentException();
+ }
+ Path toResolve = src.isAbsolute() ? src : src.getFileSystem().getPath(src.toString());
+
+ if (!Files.isDirectory(src.getParent(), LinkOption.NOFOLLOW_LINKS)) {
+ throw new FileNotFoundException(src.getParent().toString());
+ }
+
+ toResolve = toResolve.normalize();
+ try (SecureDirectoryStream<Path> ds = secureResolveDirectoryStream(toResolve.getParent())) {
+ Set<OpenOption> newOpts = new HashSet<>(opts);
+ newOpts.add(LinkOption.NOFOLLOW_LINKS);
+ return ds.newByteChannel(toResolve.getName(toResolve.getNameCount() - 1), newOpts, fileAttributes);
+ }
+ }
+
+ static SecureDirectoryStream<Path> secureResolveDirectoryStream(Path toResolve) throws IOException {
+ toResolve = IoUtils.removeCdUpAboveRoot(toResolve);
+ DirectoryStream<Path> ds = Files.newDirectoryStream(toResolve.getRoot());
+ for (int i = 0; i < toResolve.getNameCount(); i++) {
+ DirectoryStream<Path> dsOld = ds;
+ try {
+ ds = secure(ds).newDirectoryStream(toResolve.getName(i), LinkOption.NOFOLLOW_LINKS);
+ dsOld.close();
+ } catch (IOException ex) {
+ ds.close();
+ throw ex;
+ }
+ }
+ return secure(ds);
+ }
+
+ static SecureDirectoryStream<Path> secure(DirectoryStream<Path> ds) {
+ if (ds instanceof SecureDirectoryStream) {
+ return (SecureDirectoryStream<Path>) ds;
+ }
+ // do we want to bomb? do we want a different fallback option?
+ throw new UnsupportedOperationException("FS Does not support secure directory streams.");
+ }
+
default void removeFile(
SftpSubsystemProxy subsystem, Path path, boolean isDirectory)
throws IOException {
Files.delete(path);
}
+
+ default boolean noFollow(Collection<?> opts) {
+ for (Object opt : opts) {
+ if (LinkOption.NOFOLLOW_LINKS.equals(opt)) {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpSubsystem.java b/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpSubsystem.java
index f4c12ab36..65e282a02 100644
--- a/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpSubsystem.java
+++ b/sshd-sftp/src/main/java/org/apache/sshd/sftp/server/SftpSubsystem.java
@@ -686,7 +686,7 @@ protected void doReadDir(Buffer buffer, int id) throws IOException {
// the upstream server decide.
if (!(file instanceof SftpPath)) {
LinkOption[] options = getPathResolutionLinkOption(SftpConstants.SSH_FXP_READDIR, "", file);
- Boolean status = IoUtils.checkFileExists(file, options);
+ Boolean status = IoUtils.checkFileExistsAnySymlinks(file, !IoUtils.followLinks(options));
if (status == null) {
throw new AccessDeniedException(file.toString(), file.toString(), "Cannot determine existence of read-dir");
}
@@ -747,7 +747,7 @@ protected void doReadDir(Buffer buffer, int id) throws IOException {
@Override
protected String doOpenDir(int id, String path, Path dir, LinkOption... options) throws IOException {
SftpPathImpl.withAttributeCache(dir, p -> {
- Boolean status = IoUtils.checkFileExists(p, options);
+ Boolean status = IoUtils.checkFileExistsAnySymlinks(p, !IoUtils.followLinks(options));
if (status == null) {
throw signalOpenFailure(id, path, p, true,
new AccessDeniedException(p.toString(), p.toString(), "Cannot determine open-dir existence"));
@@ -807,7 +807,9 @@ protected Map<String, Object> doFStat(int id, String handle, int flags) throws I
Path file = fileHandle.getFile();
LinkOption[] options = accessor.resolveFileAccessLinkOptions(
this, file, SftpConstants.SSH_FXP_FSTAT, "", true);
- return resolveFileAttributes(file, flags, options);
+
+ boolean followLinks = resolvePathResolutionFollowLinks(SftpConstants.SSH_FXP_FSTAT, handle, file);
+ return resolveFileAttributes(file, flags, followLinks, options);
}
@Override
diff --git a/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpTest.java b/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpTest.java
index b150e3aa0..a0b408d1b 100644
--- a/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpTest.java
+++ b/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpTest.java
@@ -383,7 +383,7 @@ private void testCannotEscapeRoot(boolean useAbsolutePath) throws Exception {
SftpClient.Attributes attrs = sftp.stat(escapePath);
fail("Unexpected escape success for path=" + escapePath + ": " + attrs);
} catch (SftpException e) {
- int expected = OsUtils.isWin32() || (!useAbsolutePath)
+ int expected = OsUtils.isWin32() && useAbsolutePath
? SftpConstants.SSH_FX_INVALID_FILENAME
: SftpConstants.SSH_FX_NO_SUCH_FILE;
assertEquals("Mismatched status for " + escapePath,
@@ -711,10 +711,11 @@ public SeekableByteChannel openFile(
@Override
public DirectoryStream<Path> openDirectory(
- SftpSubsystemProxy subsystem, DirectoryHandle dirHandle, Path dir, String handle)
+ SftpSubsystemProxy subsystem, DirectoryHandle dirHandle, Path dir, String handle,
+ LinkOption... linkOptions)
throws IOException {
dirHolder.set(dir);
- return SftpFileSystemAccessor.super.openDirectory(subsystem, dirHandle, dir, handle);
+ return SftpFileSystemAccessor.super.openDirectory(subsystem, dirHandle, dir, handle, linkOptions);
}
@Override
diff --git a/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpVersionsTest.java b/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpVersionsTest.java
index d277bbc88..c18a3d7cd 100644
--- a/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpVersionsTest.java
+++ b/sshd-sftp/src/test/java/org/apache/sshd/sftp/client/SftpVersionsTest.java
@@ -291,9 +291,11 @@ public void testSftpACLEncodeDecode() throws Exception {
public Command createSubsystem(ChannelSession channel) throws IOException {
SftpSubsystem subsystem = new SftpSubsystem(channel, this) {
@Override
- protected NavigableMap<String, Object> resolveFileAttributes(Path file, int flags, LinkOption... options)
+ protected NavigableMap<String, Object> resolveFileAttributes(
+ Path file, int flags, boolean neverFollowSymLinks, LinkOption... options)
throws IOException {
- NavigableMap<String, Object> attrs = super.resolveFileAttributes(file, flags, options);
+ NavigableMap<String, Object> attrs
+ = super.resolveFileAttributes(file, flags, neverFollowSymLinks, options);
if (MapEntryUtils.isEmpty(attrs)) {
attrs = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
}
@@ -412,9 +414,11 @@ public void testSftpExtensionsEncodeDecode() throws Exception {
public Command createSubsystem(ChannelSession channel) throws IOException {
SftpSubsystem subsystem = new SftpSubsystem(channel, this) {
@Override
- protected NavigableMap<String, Object> resolveFileAttributes(Path file, int flags, LinkOption... options)
+ protected NavigableMap<String, Object> resolveFileAttributes(
+ Path file, int flags, boolean neverFollowLinks, LinkOption... options)
throws IOException {
- NavigableMap<String, Object> attrs = super.resolveFileAttributes(file, flags, options);
+ NavigableMap<String, Object> attrs
+ = super.resolveFileAttributes(file, flags, neverFollowLinks, options);
if (MapEntryUtils.isEmpty(attrs)) {
attrs = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
}